diff options
Diffstat (limited to 'app/assets')
425 files changed, 7348 insertions, 3183 deletions
diff --git a/app/assets/images/auth_buttons/salesforce_64.png b/app/assets/images/auth_buttons/salesforce_64.png Binary files differnew file mode 100644 index 00000000000..c8a86a0c515 --- /dev/null +++ b/app/assets/images/auth_buttons/salesforce_64.png diff --git a/app/assets/javascripts/analytics/cycle_analytics/mixins/filter_mixins.js b/app/assets/javascripts/analytics/cycle_analytics/mixins/filter_mixins.js new file mode 100644 index 00000000000..ff8b4c56321 --- /dev/null +++ b/app/assets/javascripts/analytics/cycle_analytics/mixins/filter_mixins.js @@ -0,0 +1 @@ +export default {}; diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 4f66a5d080c..136ffdf8b9d 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -14,6 +14,7 @@ const Api = { projectPath: '/api/:version/projects/:id', forkedProjectsPath: '/api/:version/projects/:id/forks', projectLabelsPath: '/:namespace_path/:project_path/-/labels', + projectUsersPath: '/api/:version/projects/:id/users', projectMergeRequestsPath: '/api/:version/projects/:id/merge_requests', projectMergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid', projectMergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes', @@ -24,6 +25,7 @@ const Api = { issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key', projectTemplatePath: '/api/:version/projects/:id/templates/:type/:key', projectTemplatesPath: '/api/:version/projects/:id/templates/:type', + userCountsPath: '/api/:version/user_counts', usersPath: '/api/:version/users.json', userPath: '/api/:version/users/:id', userStatusPath: '/api/:version/users/:id/status', @@ -107,6 +109,20 @@ const Api = { }); }, + projectUsers(projectPath, query = '', options = {}) { + const url = Api.buildUrl(this.projectUsersPath).replace(':id', encodeURIComponent(projectPath)); + + return axios + .get(url, { + params: { + search: query, + per_page: 20, + ...options, + }, + }) + .then(({ data }) => data); + }, + // Return single project project(projectPath) { const url = Api.buildUrl(Api.projectPath).replace(':id', encodeURIComponent(projectPath)); @@ -312,6 +328,11 @@ const Api = { }); }, + userCounts() { + const url = Api.buildUrl(this.userCountsPath); + return axios.get(url); + }, + userStatus(id, options) { const url = Api.buildUrl(this.userStatusPath).replace(':id', encodeURIComponent(id)); return axios.get(url, { diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue index ae2916e3a3b..eb720f5380b 100644 --- a/app/assets/javascripts/badges/components/badge.vue +++ b/app/assets/javascripts/badges/components/badge.vue @@ -3,6 +3,8 @@ import Icon from '~/vue_shared/components/icon.vue'; import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; export default { + // name: 'Badge' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25 + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings name: 'Badge', components: { Icon, diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js index bfb073fdcdc..137cc7b4669 100644 --- a/app/assets/javascripts/behaviors/markdown/render_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import syntaxHighlight from '~/syntax_highlight'; import renderMath from './render_math'; import renderMermaid from './render_mermaid'; +import renderMetrics from './render_metrics'; import highlightCurrentUser from './highlight_current_user'; import initUserPopovers from '../../user_popovers'; import initMRPopovers from '../../mr_popover'; @@ -17,6 +18,7 @@ $.fn.renderGFM = function renderGFM() { highlightCurrentUser(this.find('.gfm-project_member').get()); initUserPopovers(this.find('.gfm-project_member').get()); initMRPopovers(this.find('.gfm-merge_request').get()); + renderMetrics(this.find('.js-render-metrics').get()); return this; }; diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js index b23de36f860..27708504791 100644 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -33,10 +33,12 @@ export default function renderMermaid($els) { flowchart: { htmlLabels: false, }, + securityLevel: 'strict', }); $els.each((i, el) => { - const source = el.textContent; + // Mermaid doesn't like `<br />` tags, so collapse all like tags into `<br>`, which is parsed correctly. + const source = el.textContent.replace(/<br\s*\/>/g, '<br>'); /** * Restrict the rendering to a certain amount of character to diff --git a/app/assets/javascripts/behaviors/markdown/render_metrics.js b/app/assets/javascripts/behaviors/markdown/render_metrics.js new file mode 100644 index 00000000000..252b98610b6 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/render_metrics.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import Metrics from '~/monitoring/components/embed.vue'; +import { createStore } from '~/monitoring/stores'; + +// TODO: Handle copy-pasting - https://gitlab.com/gitlab-org/gitlab-ce/issues/64369. +export default function renderMetrics(elements) { + if (!elements.length) { + return; + } + + elements.forEach(element => { + const { dashboardUrl } = element.dataset; + const MetricsComponent = Vue.extend(Metrics); + + // eslint-disable-next-line no-new + new MetricsComponent({ + el: element, + store: createStore(), + propsData: { + dashboardUrl, + }, + }); + }); +} diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js index 35874140bf9..b2571fb840c 100644 --- a/app/assets/javascripts/behaviors/preview_markdown.js +++ b/app/assets/javascripts/behaviors/preview_markdown.js @@ -36,6 +36,10 @@ MarkdownPreview.prototype.showPreview = function($form) { mdText = $form.find('textarea.markdown-area').val(); + if (mdText === undefined) { + return; + } + if (mdText.trim().length === 0) { preview.text(this.emptyMessage); this.hideReferencedUsers($form); diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js index eade1283513..7e3515b1f4b 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js @@ -4,7 +4,7 @@ import Mousetrap from 'mousetrap'; import axios from '../../lib/utils/axios_utils'; import { refreshCurrentPage, visitUrl } from '../../lib/utils/url_utility'; import findAndFollowLink from '../../lib/utils/navigation_utility'; -import { parseBoolean } from '~/lib/utils/common_utils'; +import { parseBoolean, getCspNonceValue } from '~/lib/utils/common_utils'; const defaultStopCallback = Mousetrap.stopCallback; Mousetrap.stopCallback = (e, element, combo) => { @@ -94,7 +94,7 @@ export default class Shortcuts { responseType: 'text', }) .then(({ data }) => { - $.globalEval(data); + $.globalEval(data, { nonce: getCspNonceValue() }); if (location && location.length > 0) { const results = []; diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js index 8b7e6a56d25..208c91a1f08 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js @@ -6,6 +6,8 @@ export default class ShortcutsWiki extends ShortcutsNavigation { constructor() { super(); Mousetrap.bind('e', ShortcutsWiki.editWiki); + + this.enabledHelp.push('.hidden-shortcut.wiki'); } static editWiki() { diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js index 45b9e57f9ab..c6122fbc686 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -1,6 +1,7 @@ +import $ from 'jquery'; import Sortable from 'sortablejs'; import Vue from 'vue'; -import { n__ } from '~/locale'; +import { n__, s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import Tooltip from '~/vue_shared/directives/tooltip'; import AccessorUtilities from '../../lib/utils/accessor'; @@ -53,12 +54,19 @@ export default Vue.extend({ const { issuesSize } = this.list; return `${n__('%d issue', '%d issues', issuesSize)}`; }, + caretTooltip() { + return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand'); + }, isNewIssueShown() { return ( this.list.type === 'backlog' || (!this.disabled && this.list.type !== 'closed' && this.list.type !== 'blank') ); }, + uniqueKey() { + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings + return `boards.${this.boardId}.${this.list.type}.${this.list.id}`; + }, }, watch: { filter: { @@ -72,31 +80,34 @@ export default Vue.extend({ }, }, mounted() { - this.sortableOptions = getBoardSortableDefaultOptions({ + const instance = this; + + const sortableOptions = getBoardSortableDefaultOptions({ disabled: this.disabled, group: 'boards', draggable: '.is-draggable', handle: '.js-board-handle', - onEnd: e => { + onEnd(e) { sortableEnd(); + const sortable = this; + if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) { - const order = this.sortable.toArray(); + const order = sortable.toArray(); const list = boardsStore.findList('id', parseInt(e.item.dataset.id, 10)); - this.$nextTick(() => { + instance.$nextTick(() => { boardsStore.moveList(list, order); }); } }, }); - this.sortable = Sortable.create(this.$el.parentNode, this.sortableOptions); + Sortable.create(this.$el.parentNode, sortableOptions); }, created() { if (this.list.isExpandable && AccessorUtilities.isLocalStorageAccessSafe()) { - const isCollapsed = - localStorage.getItem(`boards.${this.boardId}.${this.list.type}.expanded`) === 'false'; + const isCollapsed = localStorage.getItem(`${this.uniqueKey}.expanded`) === 'false'; this.list.isExpanded = !isCollapsed; } @@ -105,16 +116,17 @@ export default Vue.extend({ showNewIssueForm() { this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm; }, - toggleExpanded(e) { - if (this.list.isExpandable && !e.target.classList.contains('js-no-trigger-collapse')) { + toggleExpanded() { + if (this.list.isExpandable) { this.list.isExpanded = !this.list.isExpanded; if (AccessorUtilities.isLocalStorageAccessSafe()) { - localStorage.setItem( - `boards.${this.boardId}.${this.list.type}.expanded`, - this.list.isExpanded, - ); + localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded); } + + // When expanding/collapsing, the tooltip on the caret button sometimes stays open. + // Close all tooltips manually to prevent dangling tooltips. + $('.tooltip').tooltip('hide'); } }, }, diff --git a/app/assets/javascripts/boards/components/board_blank_state.vue b/app/assets/javascripts/boards/components/board_blank_state.vue index 1cbd31729cd..9f26337d153 100644 --- a/app/assets/javascripts/boards/components/board_blank_state.vue +++ b/app/assets/javascripts/boards/components/board_blank_state.vue @@ -1,5 +1,6 @@ <script> -/* global ListLabel */ +import { __ } from '~/locale'; +import ListLabel from '~/boards/models/label'; import Cookies from 'js-cookie'; import boardsStore from '../stores/boards_store'; @@ -7,8 +8,8 @@ export default { data() { return { predefinedLabels: [ - new ListLabel({ title: 'To Do', color: '#F0AD4E' }), - new ListLabel({ title: 'Doing', color: '#5CB85C' }), + new ListLabel({ title: __('To Do'), color: '#F0AD4E' }), + new ListLabel({ title: __('Doing'), color: '#5CB85C' }), ], }; }, @@ -29,13 +30,17 @@ export default { }); // Save the labels - gl.boardService + boardsStore .generateDefaultLists() .then(res => res.data) .then(data => { data.forEach(listObj => { const list = boardsStore.findList('title', listObj.title); + if (!list) { + return; + } + list.id = listObj.id; list.label.id = listObj.label.id; list.getIssues().catch(() => { @@ -58,30 +63,36 @@ export default { <template> <div class="board-blank-state p-3"> - <p>Add the following default lists to your Issue Board with one click:</p> + <p> + {{ + s__('BoardBlankState|Add the following default lists to your Issue Board with one click:') + }} + </p> <ul class="list-unstyled board-blank-state-list"> <li v-for="(label, index) in predefinedLabels" :key="index"> <span :style="{ backgroundColor: label.color }" class="label-color position-relative d-inline-block rounded" - > - </span> + ></span> {{ label.title }} </li> </ul> <p> - Starting out with the default set of lists will get you right on the way to making the most of - your board. + {{ + s__( + 'BoardBlankState|Starting out with the default set of lists will get you right on the way to making the most of your board.', + ) + }} </p> <button class="btn btn-success btn-inverted btn-block" type="button" @click.stop="addDefaultLists" > - Add default lists + {{ s__('BoardBlankState|Add default lists') }} </button> <button class="btn btn-default btn-block" type="button" @click.stop="clearBlankState"> - Nevermind, I'll use my own + {{ s__("BoardBlankState|Nevermind, I'll use my own") }} </button> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index 179148b6887..faf722f61af 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -83,6 +83,7 @@ export default { }" :index="index" :data-issue-id="issue.id" + data-qa-selector="board_card" class="board-card p-3 rounded" @mousedown="mouseDown" @mousemove="mouseMove" diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue new file mode 100644 index 00000000000..ebf48cee2ae --- /dev/null +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -0,0 +1,219 @@ +<script> +import { __ } from '~/locale'; +import Flash from '~/flash'; +import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; +import { visitUrl } from '~/lib/utils/url_utility'; +import boardsStore from '~/boards/stores/boards_store'; + +const boardDefaults = { + id: false, + name: '', + labels: [], + milestone_id: undefined, + assignee: {}, + assignee_id: undefined, + weight: null, +}; + +export default { + components: { + BoardScope: () => import('ee_component/boards/components/board_scope.vue'), + DeprecatedModal, + }, + props: { + canAdminBoard: { + type: Boolean, + required: true, + }, + milestonePath: { + type: String, + required: true, + }, + labelsPath: { + type: String, + required: true, + }, + scopedIssueBoardFeatureEnabled: { + type: Boolean, + required: false, + default: false, + }, + projectId: { + type: Number, + required: false, + default: 0, + }, + groupId: { + type: Number, + required: false, + default: 0, + }, + weights: { + type: Array, + required: false, + default: () => [], + }, + enableScopedLabels: { + type: Boolean, + required: false, + default: false, + }, + scopedLabelsDocumentationLink: { + type: String, + required: false, + default: '#', + }, + }, + data() { + return { + board: { ...boardDefaults, ...this.currentBoard }, + currentBoard: boardsStore.state.currentBoard, + currentPage: boardsStore.state.currentPage, + isLoading: false, + }; + }, + computed: { + isNewForm() { + return this.currentPage === 'new'; + }, + isDeleteForm() { + return this.currentPage === 'delete'; + }, + isEditForm() { + return this.currentPage === 'edit'; + }, + isVisible() { + return this.currentPage !== ''; + }, + buttonText() { + if (this.isNewForm) { + return __('Create board'); + } + if (this.isDeleteForm) { + return __('Delete'); + } + return __('Save changes'); + }, + buttonKind() { + if (this.isNewForm) { + return 'success'; + } + if (this.isDeleteForm) { + return 'danger'; + } + return 'info'; + }, + title() { + if (this.isNewForm) { + return __('Create new board'); + } + if (this.isDeleteForm) { + return __('Delete board'); + } + if (this.readonly) { + return __('Board scope'); + } + return __('Edit board'); + }, + readonly() { + return !this.canAdminBoard; + }, + submitDisabled() { + return this.isLoading || this.board.name.length === 0; + }, + }, + mounted() { + this.resetFormState(); + if (this.$refs.name) { + this.$refs.name.focus(); + } + }, + methods: { + submit() { + if (this.board.name.length === 0) return; + this.isLoading = true; + if (this.isDeleteForm) { + gl.boardService + .deleteBoard(this.currentBoard) + .then(() => { + visitUrl(boardsStore.rootPath); + }) + .catch(() => { + Flash(__('Failed to delete board. Please try again.')); + this.isLoading = false; + }); + } else { + gl.boardService + .createBoard(this.board) + .then(resp => resp.data) + .then(data => { + visitUrl(data.board_path); + }) + .catch(() => { + Flash(__('Unable to save your changes. Please try again.')); + this.isLoading = false; + }); + } + }, + cancel() { + boardsStore.showPage(''); + }, + resetFormState() { + if (this.isNewForm) { + // Clear the form when we open the "New board" modal + this.board = { ...boardDefaults }; + } else if (this.currentBoard && Object.keys(this.currentBoard).length) { + this.board = { ...boardDefaults, ...this.currentBoard }; + } + }, + }, +}; +</script> + +<template> + <deprecated-modal + v-show="isVisible" + :hide-footer="readonly" + :title="title" + :primary-button-label="buttonText" + :kind="buttonKind" + :submit-disabled="submitDisabled" + modal-dialog-class="board-config-modal" + @cancel="cancel" + @submit="submit" + > + <template slot="body"> + <p v-if="isDeleteForm">{{ __('Are you sure you want to delete this board?') }}</p> + <form v-else class="js-board-config-modal" @submit.prevent> + <div v-if="!readonly" class="append-bottom-20"> + <label class="form-section-title label-bold" for="board-new-name">{{ + __('Board name') + }}</label> + <input + id="board-new-name" + ref="name" + v-model="board.name" + class="form-control" + type="text" + :placeholder="__('Enter board name')" + @keyup.enter="submit" + /> + </div> + + <board-scope + v-if="scopedIssueBoardFeatureEnabled" + :collapse-scope="isNewForm" + :board="board" + :can-admin-board="canAdminBoard" + :milestone-path="milestonePath" + :labels-path="labelsPath" + :scoped-labels-documentation-link="scopedLabelsDocumentationLink" + :enable-scoped-labels="enableScopedLabels" + :project-id="projectId" + :group-id="groupId" + :weights="weights" + /> + </form> + </template> + </deprecated-modal> +</template> diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index b1a8b13f3ac..de41698ca04 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -1,4 +1,5 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import Sortable from 'sortablejs'; import { GlLoadingIcon } from '@gitlab/ui'; import boardNewIssue from './board_new_issue.vue'; @@ -226,8 +227,9 @@ export default { <div :class="{ 'd-none': !list.isExpanded, 'd-flex flex-column': list.isExpanded }" class="board-list-component position-relative h-100" + data-qa-selector="board_list_cards_area" > - <div v-if="loading" class="board-list-loading text-center" aria-label="Loading issues"> + <div v-if="loading" class="board-list-loading text-center" :aria-label="__('Loading issues')"> <gl-loading-icon /> </div> <board-new-issue @@ -257,7 +259,7 @@ export default { /> <li v-if="showCount" class="board-list-count text-center" data-issue-id="-1"> <gl-loading-icon v-show="list.loadingMore" label="Loading more issues" /> - <span v-if="list.issues.length === list.issuesSize"> Showing all issues </span> + <span v-if="list.issues.length === list.issuesSize">{{ __('Showing all issues') }}</span> <span v-else> Showing {{ list.issues.length }} of {{ list.issuesSize }} issues </span> </li> </ul> diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index cc6af8e88cd..4180023b7db 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -102,9 +102,9 @@ export default { <div class="board-card position-relative p-3 rounded"> <form @submit="submit($event)"> <div v-if="error" class="flash-container"> - <div class="flash-alert">An error occurred. Please try again.</div> + <div class="flash-alert">{{ __('An error occurred. Please try again.') }}</div> </div> - <label :for="list.id + '-title'" class="label-bold"> Title </label> + <label :for="list.id + '-title'" class="label-bold">{{ __('Title') }}</label> <input :id="list.id + '-title'" ref="input" @@ -122,12 +122,11 @@ export default { class="float-left" variant="success" type="submit" + >{{ __('Submit issue') }}</gl-button > - Submit issue - </gl-button> - <gl-button class="float-right" type="button" variant="default" @click="cancel"> - Cancel - </gl-button> + <gl-button class="float-right" type="button" variant="default" @click="cancel">{{ + __('Cancel') + }}</gl-button> </div> </form> </div> diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index 2ace0060c42..ba1fe9202fc 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -22,6 +22,8 @@ export default Vue.extend({ components: { AssigneeTitle, Assignees, + SidebarEpicsSelect: () => + import('ee_component/sidebar/components/sidebar_item_epics_select.vue'), RemoveBtn, Subscriptions, TimeTracker, diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue new file mode 100644 index 00000000000..7296426549a --- /dev/null +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -0,0 +1,335 @@ +<script> +import { throttle } from 'underscore'; +import { + GlLoadingIcon, + GlSearchBoxByType, + GlDropdown, + GlDropdownDivider, + GlDropdownHeader, + GlDropdownItem, +} from '@gitlab/ui'; + +import Icon from '~/vue_shared/components/icon.vue'; +import httpStatusCodes from '~/lib/utils/http_status'; +import boardsStore from '../stores/boards_store'; +import BoardForm from './board_form.vue'; + +const MIN_BOARDS_TO_VIEW_RECENT = 10; + +export default { + name: 'BoardsSelector', + components: { + Icon, + BoardForm, + GlLoadingIcon, + GlSearchBoxByType, + GlDropdown, + GlDropdownDivider, + GlDropdownHeader, + GlDropdownItem, + }, + props: { + currentBoard: { + type: Object, + required: true, + }, + milestonePath: { + type: String, + required: true, + }, + throttleDuration: { + type: Number, + default: 200, + }, + boardBaseUrl: { + type: String, + required: true, + }, + hasMissingBoards: { + type: Boolean, + required: true, + }, + canAdminBoard: { + type: Boolean, + required: true, + }, + multipleIssueBoardsAvailable: { + type: Boolean, + required: true, + }, + labelsPath: { + type: String, + required: true, + }, + projectId: { + type: Number, + required: true, + }, + groupId: { + type: Number, + required: true, + }, + scopedIssueBoardFeatureEnabled: { + type: Boolean, + required: true, + }, + weights: { + type: Array, + required: true, + }, + enabledScopedLabels: { + type: Boolean, + required: false, + default: false, + }, + scopedLabelsDocumentationLink: { + type: String, + required: false, + default: '#', + }, + }, + data() { + return { + loading: true, + hasScrollFade: false, + scrollFadeInitialized: false, + boards: [], + recentBoards: [], + state: boardsStore.state, + throttledSetScrollFade: throttle(this.setScrollFade, this.throttleDuration), + contentClientHeight: 0, + maxPosition: 0, + store: boardsStore, + filterTerm: '', + }; + }, + computed: { + currentPage() { + return this.state.currentPage; + }, + filteredBoards() { + return this.boards.filter(board => + board.name.toLowerCase().includes(this.filterTerm.toLowerCase()), + ); + }, + reload: { + get() { + return this.state.reload; + }, + set(newValue) { + this.state.reload = newValue; + }, + }, + board() { + return this.state.currentBoard; + }, + showDelete() { + return this.boards.length > 1; + }, + scrollFadeClass() { + return { + 'fade-out': !this.hasScrollFade, + }; + }, + showRecentSection() { + return ( + this.recentBoards.length && + this.boards.length > MIN_BOARDS_TO_VIEW_RECENT && + !this.filterTerm.length + ); + }, + }, + watch: { + filteredBoards() { + this.scrollFadeInitialized = false; + this.$nextTick(this.setScrollFade); + }, + reload() { + if (this.reload) { + this.boards = []; + this.recentBoards = []; + this.loading = true; + this.reload = false; + + this.loadBoards(false); + } + }, + }, + created() { + boardsStore.setCurrentBoard(this.currentBoard); + }, + methods: { + showPage(page) { + boardsStore.showPage(page); + }, + loadBoards(toggleDropdown = true) { + if (toggleDropdown && this.boards.length > 0) { + return; + } + + const recentBoardsPromise = new Promise((resolve, reject) => + gl.boardService + .recentBoards() + .then(resolve) + .catch(err => { + /** + * If user is unauthorized we'd still want to resolve the + * request to display all boards. + */ + if (err.response.status === httpStatusCodes.UNAUTHORIZED) { + resolve({ data: [] }); // recent boards are empty + return; + } + reject(err); + }), + ); + + Promise.all([gl.boardService.allBoards(), recentBoardsPromise]) + .then(([allBoards, recentBoards]) => [allBoards.data, recentBoards.data]) + .then(([allBoardsJson, recentBoardsJson]) => { + this.loading = false; + this.boards = allBoardsJson; + this.recentBoards = recentBoardsJson; + }) + .then(() => this.$nextTick()) // Wait for boards list in DOM + .then(() => { + this.setScrollFade(); + }) + .catch(() => { + this.loading = false; + }); + }, + isScrolledUp() { + const { content } = this.$refs; + const currentPosition = this.contentClientHeight + content.scrollTop; + + return content && currentPosition < this.maxPosition; + }, + initScrollFade() { + this.scrollFadeInitialized = true; + + const { content } = this.$refs; + + this.contentClientHeight = content.clientHeight; + this.maxPosition = content.scrollHeight; + }, + setScrollFade() { + if (!this.scrollFadeInitialized) this.initScrollFade(); + + this.hasScrollFade = this.isScrolledUp(); + }, + }, +}; +</script> + +<template> + <div class="boards-switcher js-boards-selector append-right-10"> + <span class="boards-selector-wrapper js-boards-selector-wrapper"> + <gl-dropdown + data-qa-selector="boards_dropdown" + toggle-class="dropdown-menu-toggle js-dropdown-toggle" + menu-class="flex-column dropdown-extended-height" + :text="board.name" + @show="loadBoards" + > + <div> + <div class="dropdown-title mb-0" @mousedown.prevent> + {{ s__('IssueBoards|Switch board') }} + </div> + </div> + + <gl-dropdown-header class="mt-0"> + <gl-search-box-by-type ref="searchBox" v-model="filterTerm" /> + </gl-dropdown-header> + + <div + v-if="!loading" + ref="content" + class="dropdown-content flex-fill" + @scroll.passive="throttledSetScrollFade" + > + <gl-dropdown-item + v-show="filteredBoards.length === 0" + class="no-pointer-events text-secondary" + > + {{ s__('IssueBoards|No matching boards found') }} + </gl-dropdown-item> + + <h6 v-if="showRecentSection" class="dropdown-bold-header my-0"> + {{ __('Recent') }} + </h6> + + <template v-if="showRecentSection"> + <gl-dropdown-item + v-for="recentBoard in recentBoards" + :key="`recent-${recentBoard.id}`" + class="js-dropdown-item" + :href="`${boardBaseUrl}/${recentBoard.id}`" + > + {{ recentBoard.name }} + </gl-dropdown-item> + </template> + + <hr v-if="showRecentSection" class="my-1" /> + + <h6 v-if="showRecentSection" class="dropdown-bold-header my-0"> + {{ __('All') }} + </h6> + + <gl-dropdown-item + v-for="otherBoard in filteredBoards" + :key="otherBoard.id" + class="js-dropdown-item" + :href="`${boardBaseUrl}/${otherBoard.id}`" + > + {{ otherBoard.name }} + </gl-dropdown-item> + <gl-dropdown-item v-if="hasMissingBoards" class="small unclickable"> + {{ + s__( + 'IssueBoards|Some of your boards are hidden, activate a license to see them again.', + ) + }} + </gl-dropdown-item> + </div> + + <div + v-show="filteredBoards.length > 0" + class="dropdown-content-faded-mask" + :class="scrollFadeClass" + ></div> + + <gl-loading-icon v-if="loading" /> + + <div v-if="canAdminBoard"> + <gl-dropdown-divider /> + + <gl-dropdown-item v-if="multipleIssueBoardsAvailable" @click.prevent="showPage('new')"> + {{ s__('IssueBoards|Create new board') }} + </gl-dropdown-item> + + <gl-dropdown-item + v-if="showDelete" + class="text-danger" + @click.prevent="showPage('delete')" + > + {{ s__('IssueBoards|Delete board') }} + </gl-dropdown-item> + </div> + </gl-dropdown> + + <board-form + v-if="currentPage" + :milestone-path="milestonePath" + :labels-path="labelsPath" + :project-id="projectId" + :group-id="groupId" + :can-admin-board="canAdminBoard" + :scoped-issue-board-feature-enabled="scopedIssueBoardFeatureEnabled" + :weights="weights" + :enable-scoped-labels="enabledScopedLabels" + :scoped-labels-documentation-link="scopedLabelsDocumentationLink" + /> + </span> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index a8516f178fc..7f554c99669 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -124,7 +124,7 @@ export default { return `${this.rootPath}${assignee.username}`; }, avatarUrlTitle(assignee) { - return `Avatar for ${assignee.name}`; + return sprintf(__(`Avatar for %{assigneeName}`), { assigneeName: assignee.name }); }, showLabel(label) { if (!label.id) return false; @@ -160,9 +160,10 @@ export default { :title="__('Confidential')" class="confidential-icon append-right-4" :aria-label="__('Confidential')" - /><a :href="issue.path" :title="issue.title" class="js-no-trigger" @mousemove.stop>{{ - issue.title - }}</a> + /> + <a :href="issue.path" :title="issue.title" class="js-no-trigger" @mousemove.stop> + {{ issue.title }} + </a> </h4> </div> <div v-if="showLabelFooter" class="board-card-labels prepend-top-4 d-flex flex-wrap"> @@ -204,13 +205,13 @@ export default { placement="bottom" class="board-issue-path block-truncated bold" >{{ issueReferencePath }}</tooltip-on-truncate - >#{{ issue.iid }} + > + #{{ issue.iid }} </span> <span class="board-info-items prepend-top-8 d-inline-block"> - <issue-due-date v-if="issue.dueDate" :date="issue.dueDate" /><issue-time-estimate - v-if="issue.timeEstimate" - :estimate="issue.timeEstimate" - /><issue-card-weight + <issue-due-date v-if="issue.dueDate" :date="issue.dueDate" /> + <issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" /> + <issue-card-weight v-if="issue.weight" :weight="issue.weight" @click="filterByWeight(issue.weight)" @@ -230,7 +231,8 @@ export default { tooltip-placement="bottom" > <span class="js-assignee-tooltip"> - <span class="bold d-block">Assignee</span> {{ assignee.name }} + <span class="bold d-block">{{ __('Assignee') }}</span> + {{ assignee.name }} <span class="text-white-50">@{{ assignee.username }}</span> </span> </user-avatar-link> @@ -240,9 +242,8 @@ export default { :title="assigneeCounterTooltip" class="avatar-counter" data-placement="bottom" + >{{ assigneeCounterLabel }}</span > - {{ assigneeCounterLabel }} - </span> </div> </div> </div> diff --git a/app/assets/javascripts/boards/components/modal/empty_state.vue b/app/assets/javascripts/boards/components/modal/empty_state.vue index 091700de93f..66f59009714 100644 --- a/app/assets/javascripts/boards/components/modal/empty_state.vue +++ b/app/assets/javascripts/boards/components/modal/empty_state.vue @@ -1,4 +1,5 @@ <script> +import { __, sprintf } from '~/locale'; import ModalStore from '../../stores/modal_store'; import modalMixin from '../../mixins/modal_mixins'; @@ -20,19 +21,20 @@ export default { computed: { contents() { const obj = { - title: "You haven't added any issues to your project yet", - content: ` - An issue can be a bug, a todo or a feature request that needs to be - discussed in a project. Besides, issues are searchable and filterable. - `, + title: __("You haven't added any issues to your project yet"), + content: __( + 'An issue can be a bug, a todo or a feature request that needs to be discussed in a project. Besides, issues are searchable and filterable.', + ), }; if (this.activeTab === 'selected') { - obj.title = "You haven't selected any issues yet"; - obj.content = ` - Go back to <strong>Open issues</strong> and select some issues - to add to your board. - `; + obj.title = __("You haven't selected any issues yet"); + obj.content = sprintf( + __( + 'Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board.', + ), + { startTag: '<strong>', endTag: '</strong>' }, + ); } return obj; @@ -51,16 +53,16 @@ export default { <div class="text-content"> <h4>{{ contents.title }}</h4> <p v-html="contents.content"></p> - <a v-if="activeTab === 'all'" :href="newIssuePath" class="btn btn-success btn-inverted"> - New issue - </a> + <a v-if="activeTab === 'all'" :href="newIssuePath" class="btn btn-success btn-inverted">{{ + __('New issue') + }}</a> <button v-if="activeTab === 'selected'" class="btn btn-default" type="button" @click="changeTab('all')" > - Open issues + {{ __('Open issues') }} </button> </div> </div> diff --git a/app/assets/javascripts/boards/components/modal/footer.vue b/app/assets/javascripts/boards/components/modal/footer.vue index d4afd9d59da..5f100c617a0 100644 --- a/app/assets/javascripts/boards/components/modal/footer.vue +++ b/app/assets/javascripts/boards/components/modal/footer.vue @@ -1,8 +1,8 @@ <script> +import footerEEMixin from 'ee_else_ce/boards/mixins/modal_footer'; import Flash from '../../../flash'; -import { __ } from '../../../locale'; +import { __, n__ } from '../../../locale'; import ListsDropdown from './lists_dropdown.vue'; -import { pluralize } from '../../../lib/utils/text_utility'; import ModalStore from '../../stores/modal_store'; import modalMixin from '../../mixins/modal_mixins'; import boardsStore from '../../stores/boards_store'; @@ -11,7 +11,7 @@ export default { components: { ListsDropdown, }, - mixins: [modalMixin], + mixins: [modalMixin, footerEEMixin], data() { return { modal: ModalStore.store, @@ -24,8 +24,8 @@ export default { }, submitText() { const count = ModalStore.selectedCount(); - - return `Add ${count > 0 ? count : ''} ${pluralize('issue', count)}`; + if (!count) return __('Add issues'); + return n__(`Add %d issue`, `Add %d issues`, count); }, }, methods: { @@ -42,7 +42,7 @@ export default { const req = this.buildUpdateRequest(list); // Post the data to the backend - gl.boardService.bulkUpdate(issueIds, req).catch(() => { + boardsStore.bulkUpdate(issueIds, req).catch(() => { Flash(__('Failed to update issues, please try again.')); selectedIssues.forEach(issue => { @@ -68,11 +68,11 @@ export default { <button :disabled="submitDisabled" class="btn btn-success" type="button" @click="addIssues"> {{ submitText }} </button> - <span class="inline add-issues-footer-to-list"> to list </span> + <span class="inline add-issues-footer-to-list">{{ __('to list') }}</span> <lists-dropdown /> </div> <button class="btn btn-default float-right" type="button" @click="toggleModal(false)"> - Cancel + {{ __('Cancel') }} </button> </footer> </template> diff --git a/app/assets/javascripts/boards/components/modal/header.vue b/app/assets/javascripts/boards/components/modal/header.vue index 1cfa6d39362..8cd4840d3d6 100644 --- a/app/assets/javascripts/boards/components/modal/header.vue +++ b/app/assets/javascripts/boards/components/modal/header.vue @@ -1,4 +1,6 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ +import { __ } from '~/locale'; import ModalFilters from './filters'; import ModalTabs from './tabs.vue'; import ModalStore from '../../stores/modal_store'; @@ -30,10 +32,10 @@ export default { computed: { selectAllText() { if (ModalStore.selectedCount() !== this.issues.length || this.issues.length === 0) { - return 'Select all'; + return __('Select all'); } - return 'Deselect all'; + return __('Deselect all'); }, showSearch() { return this.activeTab === 'all' && !this.loading && this.issuesCount > 0; @@ -57,7 +59,7 @@ export default { type="button" class="close" data-dismiss="modal" - aria-label="Close" + :aria-label="__('Close')" @click="toggleModal(false)" > <span aria-hidden="true">×</span> diff --git a/app/assets/javascripts/boards/components/modal/list.vue b/app/assets/javascripts/boards/components/modal/list.vue index 28d2019af2f..1802b543687 100644 --- a/app/assets/javascripts/boards/components/modal/list.vue +++ b/app/assets/javascripts/boards/components/modal/list.vue @@ -123,7 +123,9 @@ export default { class="empty-state add-issues-empty-state-filter text-center" > <div class="svg-content"><img :src="emptyStateSvg" /></div> - <div class="text-content"><h4>There are no issues to show.</h4></div> + <div class="text-content"> + <h4>{{ __('There are no issues to show.') }}</h4> + </div> </div> <div v-for="(group, index) in groupedIssues" :key="index" class="add-issues-list-column"> <div v-for="issue in group" v-if="showIssue(issue)" :key="issue.id" class="board-card-parent"> diff --git a/app/assets/javascripts/boards/components/modal/tabs.vue b/app/assets/javascripts/boards/components/modal/tabs.vue index 2d2920e312e..7430fc96654 100644 --- a/app/assets/javascripts/boards/components/modal/tabs.vue +++ b/app/assets/javascripts/boards/components/modal/tabs.vue @@ -1,4 +1,5 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import ModalStore from '../../stores/modal_store'; import modalMixin from '../../mixins/modal_mixins'; diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index 8274647744f..e8d25e84be1 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -1,4 +1,5 @@ <script> +import { __ } from '~/locale'; import $ from 'jquery'; import _ from 'underscore'; import Icon from '~/vue_shared/components/icon.vue'; @@ -27,7 +28,7 @@ export default { }, computed: { selectedProjectName() { - return this.selectedProject.name || 'Select a project'; + return this.selectedProject.name || __('Select a project'); }, }, mounted() { @@ -67,13 +68,15 @@ export default { <li> <a href='#' class='dropdown-menu-link' data-project-id="${ project.id - }" data-project-name="${project.name}"> - ${_.escape(project.name)} + }" data-project-name="${project.name}" data-project-name-with-namespace="${ + project.name_with_namespace + }"> + ${_.escape(project.name_with_namespace)} </a> </li> `; }, - text: project => project.name, + text: project => project.name_with_namespace, }); }, }; @@ -81,7 +84,7 @@ export default { <template> <div> - <label class="label-bold prepend-top-10"> Project </label> + <label class="label-bold prepend-top-10">{{ __('Project') }}</label> <div ref="projectsDropdown" class="dropdown dropdown-projects"> <button class="dropdown-menu-toggle wide" @@ -92,9 +95,9 @@ export default { {{ selectedProjectName }} <icon name="chevron-down" /> </button> <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width"> - <div class="dropdown-title">Projects</div> + <div class="dropdown-title">{{ __('Projects') }}</div> <div class="dropdown-input"> - <input class="dropdown-input-field" type="search" placeholder="Search projects" /> + <input class="dropdown-input-field" type="search" :placeholder="__('Search projects')" /> <icon name="search" class="dropdown-input-search" data-hidden="true" /> </div> <div class="dropdown-content"></div> diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue index 4ab2b17301f..b84722244d1 100644 --- a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue +++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue @@ -76,7 +76,7 @@ export default Vue.extend({ <template> <div class="block list"> <button class="btn btn-default btn-block" type="button" @click="removeIssue"> - Remove from board + {{ __('Remove from board') }} </button> </div> </template> diff --git a/app/assets/javascripts/boards/config_toggle.js b/app/assets/javascripts/boards/config_toggle.js new file mode 100644 index 00000000000..2d1ec238274 --- /dev/null +++ b/app/assets/javascripts/boards/config_toggle.js @@ -0,0 +1 @@ +export default () => {}; diff --git a/app/assets/javascripts/boards/ee_functions.js b/app/assets/javascripts/boards/ee_functions.js new file mode 100644 index 00000000000..583270fcae5 --- /dev/null +++ b/app/assets/javascripts/boards/ee_functions.js @@ -0,0 +1,7 @@ +export const setPromotionState = () => {}; + +export const setWeigthFetchingState = () => {}; +export const setEpicFetchingState = () => {}; + +export const getMilestoneTitle = () => ({}); +export const getBoardsModalData = () => ({}); diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index 6b54e8baefb..b1b4b1c5508 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -2,7 +2,6 @@ import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable import FilteredSearchContainer from '../filtered_search/container'; import FilteredSearchManager from '../filtered_search/filtered_search_manager'; import boardsStore from './stores/boards_store'; -import { isEE } from '~/lib/utils/common_utils'; export default class FilteredSearchBoards extends FilteredSearchManager { constructor(store, updateUrl = false, cantEdit = []) { @@ -10,7 +9,7 @@ export default class FilteredSearchBoards extends FilteredSearchManager { page: 'boards', isGroupDecendent: true, stateFiltersSelector: '.issues-state-filters', - isGroup: isEE(), + isGroup: IS_EE, filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, }); diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index a020765f335..3bded4a3258 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -6,28 +6,38 @@ import { __ } from '~/locale'; import './models/label'; import './models/assignee'; -import FilteredSearchBoards from './filtered_search_boards'; -import eventHub from './eventhub'; +import FilteredSearchBoards from '~/boards/filtered_search_boards'; +import eventHub from '~/boards/eventhub'; import sidebarEventHub from '~/sidebar/event_hub'; -import './models/issue'; -import './models/list'; -import './models/milestone'; -import './models/project'; -import boardsStore from './stores/boards_store'; -import ModalStore from './stores/modal_store'; -import BoardService from './services/board_service'; -import modalMixin from './mixins/modal_mixins'; -import './filters/due_date_filters'; -import Board from './components/board'; -import BoardSidebar from './components/board_sidebar'; -import initNewListDropdown from './components/new_list_dropdown'; -import BoardAddIssuesModal from './components/modal/index.vue'; +import 'ee_else_ce/boards/models/issue'; +import 'ee_else_ce/boards/models/list'; +import '~/boards/models/milestone'; +import '~/boards/models/project'; +import boardsStore from '~/boards/stores/boards_store'; +import ModalStore from '~/boards/stores/modal_store'; +import BoardService from 'ee_else_ce/boards/services/board_service'; +import modalMixin from '~/boards/mixins/modal_mixins'; +import '~/boards/filters/due_date_filters'; +import Board from 'ee_else_ce/boards/components/board'; +import BoardSidebar from 'ee_else_ce/boards/components/board_sidebar'; +import initNewListDropdown from 'ee_else_ce/boards/components/new_list_dropdown'; +import BoardAddIssuesModal from '~/boards/components/modal/index.vue'; import '~/vue_shared/vue_resource_interceptor'; import { NavigationType, convertObjectPropsToCamelCase, parseBoolean, } from '~/lib/utils/common_utils'; +import boardConfigToggle from 'ee_else_ce/boards/config_toggle'; +import toggleFocusMode from 'ee_else_ce/boards/toggle_focus'; +import { + setPromotionState, + setWeigthFetchingState, + setEpicFetchingState, + getMilestoneTitle, + getBoardsModalData, +} from 'ee_else_ce/boards/ee_functions'; +import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher'; let issueBoardsApp; @@ -78,13 +88,14 @@ export default () => { }, }, created() { - gl.boardService = new BoardService({ + boardsStore.setEndpoints({ boardsEndpoint: this.boardsEndpoint, recentBoardsEndpoint: this.recentBoardsEndpoint, listsEndpoint: this.listsEndpoint, bulkUpdatePath: this.bulkUpdatePath, boardId: this.boardId, }); + gl.boardService = new BoardService(); boardsStore.rootPath = this.boardsEndpoint; eventHub.$on('updateTokens', this.updateTokens); @@ -125,6 +136,7 @@ export default () => { }); boardsStore.addBlankState(); + setPromotionState(boardsStore); this.loading = false; }) .catch(() => { @@ -139,6 +151,8 @@ export default () => { const { sidebarInfoEndpoint } = newIssue; if (sidebarInfoEndpoint && newIssue.subscribed === undefined) { newIssue.setFetchingState('subscriptions', true); + setWeigthFetchingState(newIssue, true); + setEpicFetchingState(newIssue, true); BoardService.getIssueInfo(sidebarInfoEndpoint) .then(res => res.data) .then(data => { @@ -153,6 +167,8 @@ export default () => { } = convertObjectPropsToCamelCase(data); newIssue.setFetchingState('subscriptions', false); + setWeigthFetchingState(newIssue, false); + setEpicFetchingState(newIssue, false); newIssue.updateData({ humanTimeSpent: humanTotalTimeSpent, timeSpent: totalTimeSpent, @@ -165,6 +181,7 @@ export default () => { }) .catch(() => { newIssue.setFetchingState('subscriptions', false); + setWeigthFetchingState(newIssue, false); Flash(__('An error occurred while fetching sidebar data')); }); } @@ -199,12 +216,15 @@ export default () => { el: document.getElementById('js-add-list'), data: { filters: boardsStore.state.filters, + ...getMilestoneTitle($boardApp), }, mounted() { initNewListDropdown(); }, }); + boardConfigToggle(boardsStore); + const issueBoardsModal = document.getElementById('js-add-issues-btn'); if (issueBoardsModal) { @@ -216,6 +236,7 @@ export default () => { return { modal: ModalStore.store, store: boardsStore.state, + ...getBoardsModalData($boardApp), canAdminList: this.$options.el.hasAttribute('data-can-admin-list'), }; }, @@ -278,4 +299,7 @@ export default () => { `, }); } + + toggleFocusMode(ModalStore, boardsStore, $boardApp); + mountMultipleBoardsSwitcher(); }; diff --git a/app/assets/javascripts/boards/mixins/modal_footer.js b/app/assets/javascripts/boards/mixins/modal_footer.js new file mode 100644 index 00000000000..ff8b4c56321 --- /dev/null +++ b/app/assets/javascripts/boards/mixins/modal_footer.js @@ -0,0 +1 @@ +export default {}; diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js index 636ca99952c..68ea28e68d9 100644 --- a/app/assets/javascripts/boards/mixins/sortable_default_options.js +++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js @@ -20,7 +20,7 @@ export function getBoardSortableDefaultOptions(obj) { 'ontouchstart' in window || (window.DocumentTouch && document instanceof DocumentTouch); const defaultSortOptions = Object.assign({}, sortableConfig, { - filter: '.board-delete, .btn', + filter: '.no-drag', delay: touchEnabled ? 100 : 0, scrollSensitivity: touchEnabled ? 60 : 100, scrollSpeed: 20, diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index f858b162c6b..9069b35db9a 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -5,7 +5,7 @@ import Vue from 'vue'; import './label'; -import { isEE, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import IssueProject from './project'; import boardsStore from '../stores/boards_store'; @@ -91,13 +91,13 @@ class ListIssue { addMilestone(milestone) { const miletoneId = this.milestone ? this.milestone.id : null; - if (isEE && milestone.id !== miletoneId) { + if (IS_EE && milestone.id !== miletoneId) { this.milestone = new ListMilestone(milestone); } } removeMilestone(removeMilestone) { - if (isEE && removeMilestone && removeMilestone.id === this.milestone.id) { + if (IS_EE && removeMilestone && removeMilestone.id === this.milestone.id) { this.milestone = {}; } } diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index a9d88f19146..7e0ccb9bd2a 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -4,7 +4,7 @@ import { __ } from '~/locale'; import ListLabel from './label'; import ListAssignee from './assignee'; -import { isEE, urlParamsToObject } from '~/lib/utils/common_utils'; +import { urlParamsToObject } from '~/lib/utils/common_utils'; import boardsStore from '../stores/boards_store'; import ListMilestone from './milestone'; @@ -26,6 +26,12 @@ const TYPES = { isExpandable: false, isBlank: true, }, + default: { + // includes label, assignee, and milestone lists + isPreset: false, + isExpandable: true, + isBlank: false, + }, }; class List { @@ -52,7 +58,7 @@ class List { } else if (obj.user) { this.assignee = new ListAssignee(obj.user); this.title = this.assignee.name; - } else if (isEE && obj.milestone) { + } else if (IS_EE && obj.milestone) { this.milestone = new ListMilestone(obj.milestone); this.title = this.milestone.title; } @@ -79,7 +85,7 @@ class List { entityType = 'label_id'; } else if (this.assignee) { entityType = 'assignee_id'; - } else if (isEE && this.milestone) { + } else if (IS_EE && this.milestone) { entityType = 'milestone_id'; } @@ -199,7 +205,7 @@ class List { issue.addAssignee(this.assignee); } - if (isEE && this.milestone) { + if (IS_EE && this.milestone) { if (listFrom && listFrom.type === 'milestone') { issue.removeMilestone(listFrom.milestone); } @@ -249,7 +255,7 @@ class List { } getTypeInfo(type) { - return TYPES[type] || {}; + return TYPES[type] || TYPES.default; } onNewIssueResponse(issue, data) { diff --git a/app/assets/javascripts/boards/models/milestone.js b/app/assets/javascripts/boards/models/milestone.js index 6f81d6bc6f8..7201b6e91f5 100644 --- a/app/assets/javascripts/boards/models/milestone.js +++ b/app/assets/javascripts/boards/models/milestone.js @@ -1,11 +1,9 @@ -import { isEE } from '~/lib/utils/common_utils'; - export default class ListMilestone { constructor(obj) { this.id = obj.id; this.title = obj.title; - if (isEE) { + if (IS_EE) { this.path = obj.path; this.state = obj.state; this.webUrl = obj.web_url || obj.webUrl; diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js new file mode 100644 index 00000000000..8d22f009784 --- /dev/null +++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import BoardsSelector from '~/boards/components/boards_selector.vue'; + +export default () => { + const boardsSwitcherElement = document.getElementById('js-multiple-boards-switcher'); + return new Vue({ + el: boardsSwitcherElement, + components: { + BoardsSelector, + }, + data() { + const { dataset } = boardsSwitcherElement; + + const boardsSelectorProps = { + ...dataset, + currentBoard: JSON.parse(dataset.currentBoard), + hasMissingBoards: parseBoolean(dataset.hasMissingBoards), + canAdminBoard: parseBoolean(dataset.canAdminBoard), + multipleIssueBoardsAvailable: parseBoolean(dataset.multipleIssueBoardsAvailable), + projectId: Number(dataset.projectId), + groupId: Number(dataset.groupId), + scopedIssueBoardFeatureEnabled: parseBoolean(dataset.scopedIssueBoardFeatureEnabled), + weights: JSON.parse(dataset.weights), + }; + + return { boardsSelectorProps }; + }, + render(createElement) { + return createElement(BoardsSelector, { + props: this.boardsSelectorProps, + }); + }, + }); +}; diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js index 7d463f17ab1..56a6cab6c73 100644 --- a/app/assets/javascripts/boards/services/board_service.js +++ b/app/assets/javascripts/boards/services/board_service.js @@ -1,106 +1,87 @@ -import axios from '../../lib/utils/axios_utils'; -import { mergeUrlParams } from '../../lib/utils/url_utility'; +/* eslint-disable class-methods-use-this */ +/** + * This file is intended to be deleted. + * The existing functions will removed one by one in favor of using the board store directly. + * see https://gitlab.com/gitlab-org/gitlab-ce/issues/61621 + */ -export default class BoardService { - constructor({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) { - this.boardsEndpoint = boardsEndpoint; - this.boardId = boardId; - this.listsEndpoint = listsEndpoint; - this.listsEndpointGenerate = `${listsEndpoint}/generate.json`; - this.bulkUpdatePath = bulkUpdatePath; - this.recentBoardsEndpoint = `${recentBoardsEndpoint}.json`; - } +import boardsStore from '~/boards/stores/boards_store'; +export default class BoardService { generateBoardsPath(id) { - return `${this.boardsEndpoint}${id ? `/${id}` : ''}.json`; + return boardsStore.generateBoardsPath(id); } generateIssuesPath(id) { - return `${this.listsEndpoint}${id ? `/${id}` : ''}/issues`; + return boardsStore.generateIssuesPath(id); } static generateIssuePath(boardId, id) { - return `${gon.relative_url_root}/-/boards/${boardId ? `${boardId}` : ''}/issues${ - id ? `/${id}` : '' - }`; + return boardsStore.generateIssuePath(boardId, id); } all() { - return axios.get(this.listsEndpoint); + return boardsStore.all(); } generateDefaultLists() { - return axios.post(this.listsEndpointGenerate, {}); + return boardsStore.generateDefaultLists(); } createList(entityId, entityType) { - const list = { - [entityType]: entityId, - }; - - return axios.post(this.listsEndpoint, { - list, - }); + return boardsStore.createList(entityId, entityType); } updateList(id, position) { - return axios.put(`${this.listsEndpoint}/${id}`, { - list: { - position, - }, - }); + return boardsStore.updateList(id, position); } destroyList(id) { - return axios.delete(`${this.listsEndpoint}/${id}`); + return boardsStore.destroyList(id); } getIssuesForList(id, filter = {}) { - const data = { id }; - Object.keys(filter).forEach(key => { - data[key] = filter[key]; - }); - - return axios.get(mergeUrlParams(data, this.generateIssuesPath(id))); + return boardsStore.getIssuesForList(id, filter); } moveIssue(id, fromListId = null, toListId = null, moveBeforeId = null, moveAfterId = null) { - return axios.put(BoardService.generateIssuePath(this.boardId, id), { - from_list_id: fromListId, - to_list_id: toListId, - move_before_id: moveBeforeId, - move_after_id: moveAfterId, - }); + return boardsStore.moveIssue(id, fromListId, toListId, moveBeforeId, moveAfterId); } newIssue(id, issue) { - return axios.post(this.generateIssuesPath(id), { - issue, - }); + return boardsStore.newIssue(id, issue); } getBacklog(data) { - return axios.get( - mergeUrlParams(data, `${gon.relative_url_root}/-/boards/${this.boardId}/issues.json`), - ); + return boardsStore.getBacklog(data); } bulkUpdate(issueIds, extraData = {}) { - const data = { - update: Object.assign(extraData, { - issuable_ids: issueIds.join(','), - }), - }; - - return axios.post(this.bulkUpdatePath, data); + return boardsStore.bulkUpdate(issueIds, extraData); } static getIssueInfo(endpoint) { - return axios.get(endpoint); + return boardsStore.getIssueInfo(endpoint); } static toggleIssueSubscription(endpoint) { - return axios.post(endpoint); + return boardsStore.toggleIssueSubscription(endpoint); + } + + allBoards() { + return boardsStore.allBoards(); + } + + recentBoards() { + return boardsStore.recentBoards(); + } + + createBoard(board) { + return boardsStore.createBoard(board); + } + + deleteBoard({ id }) { + return boardsStore.deleteBoard({ id }); } } diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 4ba4cde6bae..f57c684691c 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -8,6 +8,8 @@ import Cookies from 'js-cookie'; import BoardsStoreEE from 'ee_else_ce/boards/stores/boards_store_ee'; import { getUrlParamsArray, parseBoolean } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; import eventHub from '../eventhub'; const boardsStore = { @@ -28,6 +30,7 @@ const boardsStore = { }, currentPage: '', reload: false, + endpoints: {}, }, detail: { issue: {}, @@ -36,6 +39,19 @@ const boardsStore = { issue: {}, list: {}, }, + + setEndpoints({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) { + const listsEndpointGenerate = `${listsEndpoint}/generate.json`; + this.state.endpoints = { + boardsEndpoint, + boardId, + listsEndpoint, + listsEndpointGenerate, + bulkUpdatePath, + recentBoardsEndpoint: `${recentBoardsEndpoint}.json`, + }; + }, + create() { this.state.lists = []; this.filter.path = getUrlParamsArray().join('&'); @@ -229,6 +245,139 @@ const boardsStore = { setTimeTrackingLimitToHours(limitToHours) { this.timeTracking.limitToHours = parseBoolean(limitToHours); }, + + generateBoardsPath(id) { + return `${this.state.endpoints.boardsEndpoint}${id ? `/${id}` : ''}.json`; + }, + + generateIssuesPath(id) { + return `${this.state.endpoints.listsEndpoint}${id ? `/${id}` : ''}/issues`; + }, + + generateIssuePath(boardId, id) { + return `${gon.relative_url_root}/-/boards/${boardId ? `${boardId}` : ''}/issues${ + id ? `/${id}` : '' + }`; + }, + + all() { + return axios.get(this.state.endpoints.listsEndpoint); + }, + + generateDefaultLists() { + return axios.post(this.state.endpoints.listsEndpointGenerate, {}); + }, + + createList(entityId, entityType) { + const list = { + [entityType]: entityId, + }; + + return axios.post(this.state.endpoints.listsEndpoint, { + list, + }); + }, + + updateList(id, position) { + return axios.put(`${this.state.endpoints.listsEndpoint}/${id}`, { + list: { + position, + }, + }); + }, + + destroyList(id) { + return axios.delete(`${this.state.endpoints.listsEndpoint}/${id}`); + }, + + getIssuesForList(id, filter = {}) { + const data = { id }; + Object.keys(filter).forEach(key => { + data[key] = filter[key]; + }); + + return axios.get(mergeUrlParams(data, this.generateIssuesPath(id))); + }, + + moveIssue(id, fromListId = null, toListId = null, moveBeforeId = null, moveAfterId = null) { + return axios.put(this.generateIssuePath(this.state.endpoints.boardId, id), { + from_list_id: fromListId, + to_list_id: toListId, + move_before_id: moveBeforeId, + move_after_id: moveAfterId, + }); + }, + + newIssue(id, issue) { + return axios.post(this.generateIssuesPath(id), { + issue, + }); + }, + + getBacklog(data) { + return axios.get( + mergeUrlParams( + data, + `${gon.relative_url_root}/-/boards/${this.state.endpoints.boardId}/issues.json`, + ), + ); + }, + + bulkUpdate(issueIds, extraData = {}) { + const data = { + update: Object.assign(extraData, { + issuable_ids: issueIds.join(','), + }), + }; + + return axios.post(this.state.endpoints.bulkUpdatePath, data); + }, + + getIssueInfo(endpoint) { + return axios.get(endpoint); + }, + + toggleIssueSubscription(endpoint) { + return axios.post(endpoint); + }, + + allBoards() { + return axios.get(this.generateBoardsPath()); + }, + + recentBoards() { + return axios.get(this.state.endpoints.recentBoardsEndpoint); + }, + + createBoard(board) { + const boardPayload = { ...board }; + boardPayload.label_ids = (board.labels || []).map(b => b.id); + + if (boardPayload.label_ids.length === 0) { + boardPayload.label_ids = ['']; + } + + if (boardPayload.assignee) { + boardPayload.assignee_id = boardPayload.assignee.id; + } + + if (boardPayload.milestone) { + boardPayload.milestone_id = boardPayload.milestone.id; + } + + if (boardPayload.id) { + return axios.put(this.generateBoardsPath(boardPayload.id), { board: boardPayload }); + } + return axios.post(this.generateBoardsPath(), { board: boardPayload }); + }, + + deleteBoard({ id }) { + return axios.delete(this.generateBoardsPath(id)); + }, + + setCurrentBoard(board) { + this.state.currentBoard = board; + }, }; BoardsStoreEE.initEESpecific(boardsStore); diff --git a/app/assets/javascripts/boards/toggle_focus.js b/app/assets/javascripts/boards/toggle_focus.js new file mode 100644 index 00000000000..2d1ec238274 --- /dev/null +++ b/app/assets/javascripts/boards/toggle_focus.js @@ -0,0 +1 @@ +export default () => {}; diff --git a/app/assets/javascripts/branches/divergence_graph.js b/app/assets/javascripts/branches/divergence_graph.js index 670e8e9eb60..7dbaf984acf 100644 --- a/app/assets/javascripts/branches/divergence_graph.js +++ b/app/assets/javascripts/branches/divergence_graph.js @@ -1,23 +1,51 @@ import Vue from 'vue'; +import { __ } from '../locale'; +import createFlash from '../flash'; +import axios from '../lib/utils/axios_utils'; import DivergenceGraph from './components/divergence_graph.vue'; -export default () => { - document.querySelectorAll('.js-branch-divergence-graph').forEach(el => { - const { distance, aheadCount, behindCount, defaultBranch, maxCommits } = el.dataset; - - return new Vue({ - el, - render(h) { - return h(DivergenceGraph, { - props: { - defaultBranch, - distance: distance ? parseInt(distance, 10) : null, - aheadCount: parseInt(aheadCount, 10), - behindCount: parseInt(behindCount, 10), - maxCommits: parseInt(maxCommits, 10), - }, - }); - }, - }); +export function createGraphVueApp(el, data, maxCommits) { + return new Vue({ + el, + render(h) { + return h(DivergenceGraph, { + props: { + defaultBranch: 'master', + distance: data.distance ? parseInt(data.distance, 10) : null, + aheadCount: parseInt(data.ahead, 10), + behindCount: parseInt(data.behind, 10), + maxCommits, + }, + }); + }, }); +} + +export default endpoint => { + const names = [...document.querySelectorAll('.js-branch-item')].map( + ({ dataset }) => dataset.name, + ); + return axios + .get(endpoint, { + params: { names }, + }) + .then(({ data }) => { + const maxCommits = Object.entries(data).reduce((acc, [, val]) => { + const max = Math.max(...Object.values(val)); + return max > acc ? max : acc; + }, 100); + + Object.entries(data).forEach(([branchName, val]) => { + const el = document.querySelector( + `[data-name="${branchName}"] .js-branch-divergence-graph`, + ); + + if (!el) return; + + createGraphVueApp(el, val, maxCommits); + }); + }) + .catch(() => + createFlash(__('Error fetching diverging counts for branches. Please try again.')), + ); }; diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index aacfa0d87e6..5f5c8044b49 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -48,6 +48,9 @@ export default class Clusters { } = document.querySelector('.js-edit-cluster-form').dataset; this.clusterId = clusterId; + this.clusterNewlyCreatedKey = `cluster_${this.clusterId}_newly_created`; + this.clusterBannerDismissedKey = `cluster_${this.clusterId}_banner_dismissed`; + this.store = new ClustersStore(); this.store.setHelpPaths(helpPath, ingressHelpPath, ingressDnsHelpPath); this.store.setManagePrometheusPath(managePrometheusPath); @@ -81,18 +84,19 @@ export default class Clusters { this.showTokenButton = document.querySelector('.js-show-cluster-token'); this.tokenField = document.querySelector('.js-cluster-token'); this.ingressDomainHelpText = document.querySelector('.js-ingress-domain-help-text'); - this.ingressDomainSnippet = this.ingressDomainHelpText.querySelector( - '.js-ingress-domain-snippet', - ); + this.ingressDomainSnippet = + this.ingressDomainHelpText && + this.ingressDomainHelpText.querySelector('.js-ingress-domain-snippet'); Clusters.initDismissableCallout(); initSettingsPanels(); - setupToggleButtons(document.querySelector('.js-cluster-enable-toggle-area')); + const toggleButtonsContainer = document.querySelector('.js-cluster-enable-toggle-area'); + if (toggleButtonsContainer) { + setupToggleButtons(toggleButtonsContainer); + } this.initApplications(clusterType); - if (this.store.state.status !== 'created') { - this.updateContainer(null, this.store.state.status, this.store.state.statusReason); - } + this.updateContainer(null, this.store.state.status, this.store.state.statusReason); this.addListeners(); if (statusPath) { @@ -247,35 +251,56 @@ export default class Clusters { setBannerDismissedState(status, isDismissed) { if (AccessorUtilities.isLocalStorageAccessSafe()) { - window.localStorage.setItem( - `cluster_${this.clusterId}_banner_dismissed`, - `${status}_${isDismissed}`, - ); + window.localStorage.setItem(this.clusterBannerDismissedKey, `${status}_${isDismissed}`); } } isBannerDismissed(status) { let bannerState; if (AccessorUtilities.isLocalStorageAccessSafe()) { - bannerState = window.localStorage.getItem(`cluster_${this.clusterId}_banner_dismissed`); + bannerState = window.localStorage.getItem(this.clusterBannerDismissedKey); } return bannerState === `${status}_true`; } - updateContainer(prevStatus, status, error) { - this.hideAll(); + setClusterNewlyCreated(state) { + if (AccessorUtilities.isLocalStorageAccessSafe()) { + window.localStorage.setItem(this.clusterNewlyCreatedKey, Boolean(state)); + } + } + + isClusterNewlyCreated() { + // once this is true, it will always be true for a given page load + if (!this.isNewlyCreated) { + let newlyCreated; + if (AccessorUtilities.isLocalStorageAccessSafe()) { + newlyCreated = window.localStorage.getItem(this.clusterNewlyCreatedKey); + } + + this.isNewlyCreated = newlyCreated === 'true'; + } + return this.isNewlyCreated; + } - if (this.isBannerDismissed(status)) { + updateContainer(prevStatus, status, error) { + if (status !== 'created' && this.isBannerDismissed(status)) { return; } this.setBannerDismissedState(status, false); - // We poll all the time but only want the `created` banner to show when newly created - if (this.store.state.status !== 'created' || prevStatus !== this.store.state.status) { + if (prevStatus !== status) { + this.hideAll(); + switch (status) { case 'created': - this.successContainer.classList.remove('hidden'); + if (this.isClusterNewlyCreated()) { + this.setClusterNewlyCreated(false); + this.successContainer.classList.remove('hidden'); + } else if (prevStatus) { + this.setClusterNewlyCreated(true); + window.location.reload(); + } break; case 'errored': this.errorContainer.classList.remove('hidden'); @@ -292,7 +317,6 @@ export default class Clusters { this.creatingContainer.classList.remove('hidden'); break; default: - this.hideAll(); } } } diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index 4771090aa7e..64364092016 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -1,5 +1,6 @@ <script> /* eslint-disable vue/require-default-prop */ +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { GlLink, GlModalDirective } from '@gitlab/ui'; import TimeagoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; import { s__, __, sprintf } from '~/locale'; @@ -207,7 +208,7 @@ export default { return __('Updating'); } - return __('Updated'); + return this.updateSuccessful ? __('Updated to') : __('Updated'); }, updateFailureDescription() { return s__('ClusterIntegration|Update failed. Please check the logs and try again.'); @@ -331,8 +332,6 @@ export default { class="form-text text-muted label p-0 js-cluster-application-update-details" > {{ versionLabel }} - <span v-if="updateSuccessful">to</span> - <gl-link v-if="updateSuccessful" :href="chartRepo" diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue index 480228619a5..e26ef135bc5 100644 --- a/app/assets/javascripts/clusters/components/knative_domain_editor.vue +++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue @@ -2,7 +2,7 @@ import LoadingButton from '~/vue_shared/components/loading_button.vue'; import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; import { GlLoadingIcon } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; import { APPLICATION_STATUS } from '~/clusters/constants'; @@ -32,7 +32,7 @@ export default { return [UPDATING].includes(this.knative.status); }, saveButtonLabel() { - return this.saving ? this.__('Saving') : this.__('Save changes'); + return this.saving ? __('Saving') : __('Save changes'); }, knativeInstalled() { return this.knative.installed; @@ -122,9 +122,9 @@ export default { `ClusterIntegration|To access your application after deployment, point a wildcard DNS to the Knative Endpoint.`, ) }} - <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> - {{ __('More information') }} - </a> + <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">{{ + __('More information') + }}</a> </p> <p diff --git a/app/assets/javascripts/clusters/components/uninstall_application_button.vue b/app/assets/javascripts/clusters/components/uninstall_application_button.vue index ef4bcbe14dd..8465312d84d 100644 --- a/app/assets/javascripts/clusters/components/uninstall_application_button.vue +++ b/app/assets/javascripts/clusters/components/uninstall_application_button.vue @@ -1,6 +1,7 @@ <script> import LoadingButton from '~/vue_shared/components/loading_button.vue'; import { APPLICATION_STATUS } from '~/clusters/constants'; +import { __ } from '~/locale'; const { UPDATING, UNINSTALLING } = APPLICATION_STATUS; @@ -22,7 +23,7 @@ export default { return this.status === UNINSTALLING; }, label() { - return this.loading ? this.__('Uninstalling') : this.__('Uninstall'); + return this.loading ? __('Uninstalling') : __('Uninstall'); }, }, }; diff --git a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue index 65827f1cb6a..4f60e543666 100644 --- a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue +++ b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue @@ -2,19 +2,26 @@ import { GlModal } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; import trackUninstallButtonClickMixin from 'ee_else_ce/clusters/mixins/track_uninstall_button_click'; -import { INGRESS, CERT_MANAGER, PROMETHEUS, RUNNER, KNATIVE, JUPYTER } from '../constants'; +import { HELM, INGRESS, CERT_MANAGER, PROMETHEUS, RUNNER, KNATIVE, JUPYTER } from '../constants'; const CUSTOM_APP_WARNING_TEXT = { + [HELM]: s__( + 'ClusterIntegration|The associated Tiller pod will be deleted and cannot be restored.', + ), [INGRESS]: s__( 'ClusterIntegration|The associated load balancer and IP will be deleted and cannot be restored.', ), [CERT_MANAGER]: s__( - 'ClusterIntegration|The associated certifcate will be deleted and cannot be restored.', + 'ClusterIntegration|The associated private key will be deleted and cannot be restored.', ), [PROMETHEUS]: s__('ClusterIntegration|All data will be deleted and cannot be restored.'), [RUNNER]: s__('ClusterIntegration|Any running pipelines will be canceled.'), - [KNATIVE]: s__('ClusterIntegration|The associated IP will be deleted and cannot be restored.'), - [JUPYTER]: '', + [KNATIVE]: s__( + 'ClusterIntegration|The associated IP and all deployed services will be deleted and cannot be restored. Uninstalling Knative will also remove Istio from your cluster. This will not effect any other applications.', + ), + [JUPYTER]: s__( + 'ClusterIntegration|All data not committed to GitLab will be deleted and cannot be restored.', + ), }; export default { diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index f64f0ca616f..ada5a49e246 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -171,6 +171,7 @@ export default class ClusterStore { this.state.applications.cert_manager.email || serverAppEntry.email; } else if (appId === JUPYTER) { this.state.applications.jupyter.hostname = + this.state.applications.jupyter.hostname || serverAppEntry.hostname || (this.state.applications.ingress.externalIp ? `jupyter.${this.state.applications.ingress.externalIp}.nip.io` diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js index 54e2589c707..7dd75d03ab9 100644 --- a/app/assets/javascripts/commits.js +++ b/app/assets/javascripts/commits.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import { pluralize } from './lib/utils/text_utility'; +import { n__ } from '~/locale'; import { localTimeAgo } from './lib/utils/datetime_utility'; import Pager from './pager'; import axios from './lib/utils/axios_utils'; @@ -90,9 +90,10 @@ export default class CommitsList { .first() .find('li.commit').length, ); + $commitsHeadersLast .find('span.commits-count') - .text(`${commitsCount} ${pluralize('commit', commitsCount)}`); + .text(n__('%d commit', '%d commits', commitsCount)); } localTimeAgo($processedData.find('.js-timeago')); diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js index 0d2fe2925d8..ad0f6cc1496 100644 --- a/app/assets/javascripts/commons/index.js +++ b/app/assets/javascripts/commons/index.js @@ -4,3 +4,6 @@ import './jquery'; import './bootstrap'; import './vue'; import '../lib/utils/axios_utils'; +import { openUserCountsBroadcast } from './nav/user_merge_requests'; + +openUserCountsBroadcast(); diff --git a/app/assets/javascripts/commons/nav/user_merge_requests.js b/app/assets/javascripts/commons/nav/user_merge_requests.js new file mode 100644 index 00000000000..8e694cca6a1 --- /dev/null +++ b/app/assets/javascripts/commons/nav/user_merge_requests.js @@ -0,0 +1,67 @@ +import Api from '~/api'; + +let channel; + +function broadcastCount(newCount) { + if (!channel) { + return; + } + + channel.postMessage(newCount); +} + +function updateUserMergeRequestCounts(newCount) { + const mergeRequestsCountEl = document.querySelector('.merge-requests-count'); + mergeRequestsCountEl.textContent = newCount.toLocaleString(); + mergeRequestsCountEl.classList.toggle('hidden', Number(newCount) === 0); +} + +/** + * Refresh user counts (and broadcast if open) + */ +export function refreshUserMergeRequestCounts() { + return Api.userCounts() + .then(({ data }) => { + const count = data.merge_requests; + + updateUserMergeRequestCounts(count); + broadcastCount(count); + }) + .catch(ex => { + console.error(ex); // eslint-disable-line no-console + }); +} + +/** + * Close the broadcast channel for user counts + */ +export function closeUserCountsBroadcast() { + if (!channel) { + return; + } + + channel.close(); + channel = null; +} + +/** + * Open the broadcast channel for user counts, adds user id so we only update + * + * **Please note:** + * Not supported in all browsers, but not polyfilling for now + * to keep bundle size small and + * no special functionality lost except cross tab notifications + */ +export function openUserCountsBroadcast() { + closeUserCountsBroadcast(); + + if (window.BroadcastChannel) { + const currentUserId = typeof gon !== 'undefined' && gon && gon.current_user_id; + if (currentUserId) { + channel = new BroadcastChannel(`mr_count_channel_${currentUserId}`); + channel.onmessage = ev => { + updateUserMergeRequestCounts(ev.data); + }; + } + } +} diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js index a4394ab7e92..7a6ad3dc771 100644 --- a/app/assets/javascripts/commons/polyfills.js +++ b/app/assets/javascripts/commons/polyfills.js @@ -13,6 +13,7 @@ import 'core-js/es/string/code-point-at'; import 'core-js/es/string/from-code-point'; import 'core-js/es/string/includes'; import 'core-js/es/string/starts-with'; +import 'core-js/es/string/ends-with'; import 'core-js/es/symbol'; import 'core-js/es/map'; import 'core-js/es/weak-map'; diff --git a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue new file mode 100644 index 00000000000..444640980af --- /dev/null +++ b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue @@ -0,0 +1,58 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { __ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + GlDropdown, + GlDropdownItem, + Icon, + }, + props: { + projects: { + type: Array, + required: true, + }, + selectedProject: { + type: Object, + required: false, + default: () => ({}), + }, + }, + computed: { + dropdownText() { + if (Object.keys(this.selectedProject).length) { + return this.selectedProject.name; + } + + return __('Select private project'); + }, + }, + methods: { + selectProject(project) { + this.$emit('click', project); + }, + }, +}; +</script> + +<template> + <gl-dropdown toggle-class="d-flex align-items-center w-100" class="w-100"> + <template slot="button-content"> + <span class="str-truncated-100 mr-2"> + <icon name="lock" /> + {{ dropdownText }} + </span> + <icon name="chevron-down" class="ml-auto" /> + </template> + <gl-dropdown-item v-for="project in projects" :key="project.id" @click="selectProject(project)"> + <icon + name="mobile-issue-close" + :class="{ icon: project.id !== selectedProject.id }" + class="js-active-project-check" + /> + <span class="ml-1">{{ project.name }}</span> + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue new file mode 100644 index 00000000000..197a0706062 --- /dev/null +++ b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue @@ -0,0 +1,140 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import { __, sprintf } from '../../locale'; +import createFlash from '../../flash'; +import Api from '../../api'; +import state from '../state'; +import Dropdown from './dropdown.vue'; + +export default { + components: { + GlLink, + Dropdown, + }, + props: { + namespacePath: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + newForkPath: { + type: String, + required: true, + }, + helpPagePath: { + type: String, + required: true, + }, + }, + data() { + return { + projects: [], + }; + }, + computed: { + selectedProject() { + return state.selectedProject; + }, + noForkText() { + return sprintf( + __( + "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private.", + ), + { link_start: `<a href="${this.newForkPath}" class="help-link">`, link_end: '</a>' }, + false, + ); + }, + }, + mounted() { + this.fetchProjects(); + this.createBtn = document.querySelector('.js-create-target'); + this.warningText = document.querySelector('.js-exposed-info-warning'); + }, + methods: { + selectProject(project) { + if (project) { + Object.assign(state, { + selectedProject: project, + }); + + if (project.namespaceFullPath !== this.namespacePath) { + this.showWarning(); + } + } else if (this.createBtn) { + this.createBtn.setAttribute('disabled', 'disabled'); + } + }, + normalizeProjectData(data) { + return data.map(p => ({ + id: p.id, + name: p.name_with_namespace, + pathWithNamespace: p.path_with_namespace, + namespaceFullpath: p.namespace.full_path, + })); + }, + fetchProjects() { + Api.projectForks(this.projectPath, { + with_merge_requests_enabled: true, + min_access_level: 30, + visibility: 'private', + }) + .then(({ data }) => { + this.projects = this.normalizeProjectData(data); + this.selectProject(this.projects[0]); + }) + .catch(e => { + createFlash(__('Error fetching forked projects. Please try again.')); + throw e; + }); + }, + showWarning() { + if (this.warningText) { + this.warningText.classList.remove('hidden'); + } + + if (this.createBtn) { + this.createBtn.classList.add('btn-warning'); + this.createBtn.classList.remove('btn-success'); + } + }, + }, +}; +</script> + +<template> + <div class="confidential-merge-request-fork-group form-group"> + <label>{{ __('Project') }}</label> + <div> + <dropdown + v-if="projects.length" + :projects="projects" + :selected-project="selectedProject" + @click="selectProject" + /> + <p class="text-muted mt-1 mb-0"> + <template v-if="projects.length"> + {{ + __( + "To protect this issue's confidentiality, a private fork of this project was selected.", + ) + }} + </template> + <template v-else> + {{ __('No forks available to you.') }}<br /> + <span v-html="noForkText"></span> + </template> + <gl-link + :href="helpPagePath" + class="w-auto p-0 d-inline-block text-primary bg-transparent" + target="_blank" + > + <span class="sr-only">{{ __('Read more') }}</span> + <i class="fa fa-question-circle" aria-hidden="true"></i> + </gl-link> + </p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/confidential_merge_request/index.js b/app/assets/javascripts/confidential_merge_request/index.js new file mode 100644 index 00000000000..9672821d30e --- /dev/null +++ b/app/assets/javascripts/confidential_merge_request/index.js @@ -0,0 +1,30 @@ +import Vue from 'vue'; +import { parseBoolean } from '../lib/utils/common_utils'; +import ProjectFormGroup from './components/project_form_group.vue'; +import state from './state'; + +export function isConfidentialIssue() { + return parseBoolean(document.querySelector('.js-create-mr').dataset.isConfidential); +} + +export function canCreateConfidentialMergeRequest() { + return isConfidentialIssue() && Object.keys(state.selectedProject).length > 0; +} + +export function init() { + const el = document.getElementById('js-forked-project'); + + return new Vue({ + el, + render(h) { + return h(ProjectFormGroup, { + props: { + namespacePath: el.dataset.namespacePath, + projectPath: el.dataset.projectPath, + newForkPath: el.dataset.newForkPath, + helpPagePath: el.dataset.helpPagePath, + }, + }); + }, + }); +} diff --git a/app/assets/javascripts/confidential_merge_request/state.js b/app/assets/javascripts/confidential_merge_request/state.js new file mode 100644 index 00000000000..95b0580f4b9 --- /dev/null +++ b/app/assets/javascripts/confidential_merge_request/state.js @@ -0,0 +1,5 @@ +import Vue from 'vue'; + +export default Vue.observable({ + selectedProject: {}, +}); diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_dropdown_mixin.js b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_dropdown_mixin.js index 5a3407693e5..5a3407693e5 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_dropdown_mixin.js +++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_dropdown_mixin.js diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue index 83811ab489a..83811ab489a 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue +++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue index a2eb79af4f9..a2eb79af4f9 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue +++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue index fd5d5f86401..fd5d5f86401 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue +++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/constants.js b/app/assets/javascripts/create_cluster/gke_cluster/constants.js index 2a1c0819916..2a1c0819916 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/constants.js +++ b/app/assets/javascripts/create_cluster/gke_cluster/constants.js diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/index.js b/app/assets/javascripts/create_cluster/gke_cluster/index.js index 729b9404b64..729b9404b64 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/index.js +++ b/app/assets/javascripts/create_cluster/gke_cluster/index.js diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js b/app/assets/javascripts/create_cluster/gke_cluster/store/actions.js index f05ad7773a2..f05ad7773a2 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js +++ b/app/assets/javascripts/create_cluster/gke_cluster/store/actions.js diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js b/app/assets/javascripts/create_cluster/gke_cluster/store/getters.js index f9e2e2f74fb..f9e2e2f74fb 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js +++ b/app/assets/javascripts/create_cluster/gke_cluster/store/getters.js diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/index.js b/app/assets/javascripts/create_cluster/gke_cluster/store/index.js index 5f72060633e..5f72060633e 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/index.js +++ b/app/assets/javascripts/create_cluster/gke_cluster/store/index.js diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/mutation_types.js b/app/assets/javascripts/create_cluster/gke_cluster/store/mutation_types.js index 45a91efc2d9..45a91efc2d9 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/mutation_types.js +++ b/app/assets/javascripts/create_cluster/gke_cluster/store/mutation_types.js diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/mutations.js b/app/assets/javascripts/create_cluster/gke_cluster/store/mutations.js index 88a2c1b630d..88a2c1b630d 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/mutations.js +++ b/app/assets/javascripts/create_cluster/gke_cluster/store/mutations.js diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/state.js b/app/assets/javascripts/create_cluster/gke_cluster/store/state.js index 9f3c473d4bc..9f3c473d4bc 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/state.js +++ b/app/assets/javascripts/create_cluster/gke_cluster/store/state.js diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js index 8f5cece0788..dce9c1a5410 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -5,6 +5,12 @@ import Flash from './flash'; import DropLab from './droplab/drop_lab'; import ISetter from './droplab/plugins/input_setter'; import { __, sprintf } from './locale'; +import { + init as initConfidentialMergeRequest, + isConfidentialIssue, + canCreateConfidentialMergeRequest, +} from './confidential_merge_request'; +import confidentialMergeRequestState from './confidential_merge_request/state'; // Todo: Remove this when fixing issue in input_setter plugin const InputSetter = Object.assign({}, ISetter); @@ -12,6 +18,17 @@ const InputSetter = Object.assign({}, ISetter); const CREATE_MERGE_REQUEST = 'create-mr'; const CREATE_BRANCH = 'create-branch'; +function createEndpoint(projectPath, endpoint) { + if (canCreateConfidentialMergeRequest()) { + return endpoint.replace( + projectPath, + confidentialMergeRequestState.selectedProject.pathWithNamespace, + ); + } + + return endpoint; +} + export default class CreateMergeRequestDropdown { constructor(wrapperEl) { this.wrapperEl = wrapperEl; @@ -42,6 +59,8 @@ export default class CreateMergeRequestDropdown { this.refIsValid = true; this.refsPath = this.wrapperEl.dataset.refsPath; this.suggestedRef = this.refInput.value; + this.projectPath = this.wrapperEl.dataset.projectPath; + this.projectId = this.wrapperEl.dataset.projectId; // These regexps are used to replace // a backend generated new branch name and its source (ref) @@ -58,6 +77,14 @@ export default class CreateMergeRequestDropdown { }; this.init(); + + if (isConfidentialIssue()) { + this.createMergeRequestButton.setAttribute( + 'data-dropdown-trigger', + '#create-merge-request-dropdown', + ); + initConfidentialMergeRequest(); + } } available() { @@ -113,7 +140,9 @@ export default class CreateMergeRequestDropdown { this.isCreatingBranch = true; return axios - .post(this.createBranchPath) + .post(createEndpoint(this.projectPath, this.createBranchPath), { + confidential_issue_project_id: canCreateConfidentialMergeRequest() ? this.projectId : null, + }) .then(({ data }) => { this.branchCreated = true; window.location.href = data.url; @@ -125,7 +154,11 @@ export default class CreateMergeRequestDropdown { this.isCreatingMergeRequest = true; return axios - .post(this.createMrPath) + .post(this.createMrPath, { + target_project_id: canCreateConfidentialMergeRequest() + ? confidentialMergeRequestState.selectedProject.id + : null, + }) .then(({ data }) => { this.mergeRequestCreated = true; window.location.href = data.url; @@ -149,6 +182,8 @@ export default class CreateMergeRequestDropdown { } enable() { + if (isConfidentialIssue() && !canCreateConfidentialMergeRequest()) return; + this.createMergeRequestButton.classList.remove('disabled'); this.createMergeRequestButton.removeAttribute('disabled'); @@ -205,7 +240,7 @@ export default class CreateMergeRequestDropdown { if (!ref) return false; return axios - .get(`${this.refsPath}${encodeURIComponent(ref)}`) + .get(`${createEndpoint(this.projectPath, this.refsPath)}${encodeURIComponent(ref)}`) .then(({ data }) => { const branches = data[Object.keys(data)[0]]; const tags = data[Object.keys(data)[1]]; @@ -325,6 +360,12 @@ export default class CreateMergeRequestDropdown { let xhr = null; event.preventDefault(); + if (isConfidentialIssue() && !event.target.classList.contains('js-create-target')) { + this.droplab.hooks.forEach(hook => hook.list.toggle()); + + return; + } + if (this.isBusy()) { return; } diff --git a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue new file mode 100644 index 00000000000..d946594a069 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue @@ -0,0 +1,41 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import { GlButton } from '@gitlab/ui'; + +export default { + name: 'StageCardListItem', + components: { + Icon, + GlButton, + }, + props: { + isActive: { + type: Boolean, + required: true, + }, + canEdit: { + type: Boolean, + default: false, + required: false, + }, + }, +}; +</script> + +<template> + <div :class="{ active: isActive }" class="stage-nav-item d-flex pl-4 pr-4 m-0 mb-1 ml-2 rounded"> + <slot></slot> + <div v-if="canEdit" class="dropdown"> + <gl-button + :title="__('More actions')" + class="more-actions-toggle btn btn-transparent p-0" + data-toggle="dropdown" + > + <icon css-classes="icon" name="ellipsis_v" /> + </gl-button> + <ul class="more-actions-dropdown dropdown-menu dropdown-open-left"> + <slot name="dropdown-options"></slot> + </ul> + </div> + </div> +</template> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue new file mode 100644 index 00000000000..004d335f572 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue @@ -0,0 +1,88 @@ +<script> +import StageCardListItem from './stage_card_list_item.vue'; + +export default { + name: 'StageNavItem', + components: { + StageCardListItem, + }, + props: { + isDefaultStage: { + type: Boolean, + default: false, + required: false, + }, + isActive: { + type: Boolean, + default: false, + required: false, + }, + isUserAllowed: { + type: Boolean, + required: true, + }, + title: { + type: String, + required: true, + }, + value: { + type: String, + default: '', + required: false, + }, + canEdit: { + type: Boolean, + default: false, + required: false, + }, + }, + computed: { + hasValue() { + return this.value && this.value.length > 0; + }, + editable() { + return this.isUserAllowed && this.canEdit; + }, + }, +}; +</script> + +<template> + <li @click="$emit('select')"> + <stage-card-list-item :is-active="isActive" :can-edit="editable"> + <div class="stage-nav-item-cell stage-name p-0" :class="{ 'font-weight-bold': isActive }"> + {{ title }} + </div> + <div class="stage-nav-item-cell stage-median mr-4"> + <template v-if="isUserAllowed"> + <span v-if="hasValue">{{ value }}</span> + <span v-else class="stage-empty">{{ __('Not enough data') }}</span> + </template> + <template v-else> + <span class="not-available">{{ __('Not available') }}</span> + </template> + </div> + <template v-slot:dropdown-options> + <template v-if="isDefaultStage"> + <li> + <button type="button" class="btn-default btn-transparent"> + {{ __('Hide stage') }} + </button> + </li> + </template> + <template v-else> + <li> + <button type="button" class="btn-default btn-transparent"> + {{ __('Edit stage') }} + </button> + </li> + <li> + <button type="button" class="btn-danger danger"> + {{ __('Remove stage') }} + </button> + </li> + </template> + </template> + </stage-card-list-item> + </li> +</template> diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index b56e08175cc..b3ae47af750 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -1,7 +1,10 @@ import $ from 'jquery'; import Vue from 'vue'; import Cookies from 'js-cookie'; +import { GlEmptyState } from '@gitlab/ui'; +import filterMixins from 'ee_else_ce/analytics/cycle_analytics/mixins/filter_mixins'; import Flash from '../flash'; +import { __ } from '~/locale'; import Translate from '../vue_shared/translate'; import banner from './components/banner.vue'; import stageCodeComponent from './components/stage_code_component.vue'; @@ -9,20 +12,22 @@ import stageComponent from './components/stage_component.vue'; import stageReviewComponent from './components/stage_review_component.vue'; import stageStagingComponent from './components/stage_staging_component.vue'; import stageTestComponent from './components/stage_test_component.vue'; +import stageNavItem from './components/stage_nav_item.vue'; import CycleAnalyticsService from './cycle_analytics_service'; import CycleAnalyticsStore from './cycle_analytics_store'; -import { __ } from '~/locale'; Vue.use(Translate); export default () => { const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed'; + const cycleAnalyticsEl = document.querySelector('#cycle-analytics'); // eslint-disable-next-line no-new new Vue({ el: '#cycle-analytics', name: 'CycleAnalytics', components: { + GlEmptyState, banner, 'stage-issue-component': stageComponent, 'stage-plan-component': stageComponent, @@ -31,13 +36,16 @@ export default () => { 'stage-review-component': stageReviewComponent, 'stage-staging-component': stageStagingComponent, 'stage-production-component': stageComponent, + GroupsDropdownFilter: () => + import('ee_component/analytics/shared/components/groups_dropdown_filter.vue'), + ProjectsDropdownFilter: () => + import('ee_component/analytics/shared/components/projects_dropdown_filter.vue'), + DateRangeDropdown: () => + import('ee_component/analytics/shared/components/date_range_dropdown.vue'), + 'stage-nav-item': stageNavItem, }, + mixins: [filterMixins], data() { - const cycleAnalyticsEl = document.querySelector('#cycle-analytics'); - const cycleAnalyticsService = new CycleAnalyticsService({ - requestPath: cycleAnalyticsEl.dataset.requestPath, - }); - return { store: CycleAnalyticsStore, state: CycleAnalyticsStore.state, @@ -47,7 +55,7 @@ export default () => { hasError: false, startDate: 30, isOverviewDialogDismissed: Cookies.get(OVERVIEW_DIALOG_COOKIE), - service: cycleAnalyticsService, + service: this.createCycleAnalyticsService(cycleAnalyticsEl.dataset.requestPath), }; }, computed: { @@ -56,7 +64,13 @@ export default () => { }, }, created() { - this.fetchCycleAnalyticsData(); + // Conditional check placed here to prevent this method from being called on the + // new Cycle Analytics page (i.e. the new page will be initialized blank and only + // after a group is selected the cycle analyitcs data will be fetched). Once the + // old (current) page has been removed this entire created method as well as the + // variable itself can be completely removed. + // Follow up issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/64490 + if (cycleAnalyticsEl.dataset.requestPath) this.fetchCycleAnalyticsData(); }, methods: { handleError() { @@ -118,6 +132,7 @@ export default () => { .fetchStageData({ stage, startDate: this.startDate, + projectIds: this.selectedProjectIds, }) .then(response => { this.isEmptyStage = !response.events.length; @@ -133,6 +148,11 @@ export default () => { this.isOverviewDialogDismissed = true; Cookies.set(OVERVIEW_DIALOG_COOKIE, '1', { expires: 365 }); }, + createCycleAnalyticsService(requestPath) { + return new CycleAnalyticsService({ + requestPath, + }); + }, }, }); }; diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js index a0426301a0a..babbfe93082 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js @@ -8,22 +8,26 @@ export default class CycleAnalyticsService { } fetchCycleAnalyticsData(options = { startDate: 30 }) { + const { startDate, projectIds } = options; + return this.axios .get('', { params: { - 'cycle_analytics[start_date]': options.startDate, + 'cycle_analytics[start_date]': startDate, + 'cycle_analytics[project_ids]': projectIds, }, }) .then(x => x.data); } fetchStageData(options) { - const { stage, startDate } = options; + const { stage, startDate, projectIds } = options; return this.axios .get(`events/${stage.name}.json`, { params: { 'cycle_analytics[start_date]': startDate, + 'cycle_analytics[project_ids]': projectIds, }, }) .then(x => x.data); diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js index f66e07ba31a..7817b41514d 100644 --- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js +++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js @@ -32,15 +32,15 @@ const CommentAndResolveBtn = Vue.extend({ buttonText: function() { if (this.isDiscussionResolved) { if (this.textareaIsEmpty) { - return __('Unresolve discussion'); + return __('Unresolve thread'); } else { - return __('Comment & unresolve discussion'); + return __('Comment & unresolve thread'); } } else { if (this.textareaIsEmpty) { - return __('Resolve discussion'); + return __('Resolve thread'); } else { - return __('Comment & resolve discussion'); + return __('Comment & resolve thread'); } } }, diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 81da0754752..19b85710710 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -305,7 +305,7 @@ export default { <div v-show="showTreeList" :style="{ width: `${treeWidth}px` }" - class="diff-tree-list js-diff-tree-list" + class="diff-tree-list js-diff-tree-list mr-3" > <panel-resizer :size.sync="treeWidth" diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index 58d5b658b17..c82b4a7abc6 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -49,8 +49,8 @@ export default { return this.author.id ? this.author.id : ''; }, authorUrl() { - // TODO: when the vue i18n rules are merged need to disable @gitlab/i18n/no-non-i18n-strings // name: 'mailto:' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings return this.author.web_url || `mailto:${this.commit.author_email}`; }, authorAvatar() { diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index 363ebad1594..2e57a47f2f7 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -1,4 +1,5 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { mapActions, mapGetters, mapState } from 'vuex'; import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue new file mode 100644 index 00000000000..531ebaddacd --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue @@ -0,0 +1,54 @@ +<script> +import { mapGetters } from 'vuex'; +import NoteSignedOutWidget from '~/notes/components/note_signed_out_widget.vue'; +import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; + +export default { + name: 'DiffDiscussionReply', + components: { + NoteSignedOutWidget, + ReplyPlaceholder, + UserAvatarLink, + }, + props: { + hasForm: { + type: Boolean, + required: false, + default: false, + }, + renderReplyPlaceholder: { + type: Boolean, + required: true, + }, + }, + computed: { + ...mapGetters({ + currentUser: 'getUserData', + userCanReply: 'userCanReply', + }), + }, +}; +</script> + +<template> + <div class="discussion-reply-holder d-flex clearfix"> + <template v-if="userCanReply"> + <slot v-if="hasForm" name="form"></slot> + <template v-else-if="renderReplyPlaceholder"> + <user-avatar-link + :link-href="currentUser.path" + :img-src="currentUser.avatar_url" + :img-alt="currentUser.name" + :img-size="40" + class="d-none d-sm-block" + /> + <reply-placeholder + :button-text="__('Start a new discussion...')" + @onClick="$emit('showNewDiscussionForm')" + /> + </template> + </template> + <note-signed-out-widget v-else /> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue index 4c73eea4049..b0460bacff2 100644 --- a/app/assets/javascripts/diffs/components/diff_discussions.vue +++ b/app/assets/javascripts/diffs/components/diff_discussions.vue @@ -80,7 +80,6 @@ export default { v-show="isExpanded(discussion)" :discussion="discussion" :render-diff-file="false" - :always-expanded="true" :discussions-by-diff-order="true" :line="line" :help-page-path="helpPagePath" diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue new file mode 100644 index 00000000000..839ab542377 --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue @@ -0,0 +1,244 @@ +<script> +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import { mapState, mapActions } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import { UNFOLD_COUNT } from '../constants'; +import * as utils from '../store/utils'; +import tooltip from '../../vue_shared/directives/tooltip'; + +const EXPAND_ALL = 0; +const EXPAND_UP = 1; +const EXPAND_DOWN = 2; + +export default { + directives: { + tooltip, + }, + components: { + Icon, + }, + props: { + fileHash: { + type: String, + required: true, + }, + contextLinesPath: { + type: String, + required: true, + }, + line: { + type: Object, + required: true, + }, + isTop: { + type: Boolean, + required: false, + default: false, + }, + isBottom: { + type: Boolean, + required: false, + default: false, + }, + colspan: { + type: Number, + required: false, + default: 3, + }, + }, + computed: { + ...mapState({ + diffViewType: state => state.diffs.diffViewType, + diffFiles: state => state.diffs.diffFiles, + }), + canExpandUp() { + return !this.isBottom; + }, + canExpandDown() { + return this.isBottom || !this.isTop; + }, + }, + created() { + this.EXPAND_DOWN = EXPAND_DOWN; + this.EXPAND_UP = EXPAND_UP; + }, + methods: { + ...mapActions('diffs', ['loadMoreLines']), + getPrevLineNumber(oldLineNumber, newLineNumber) { + const diffFile = utils.findDiffFile(this.diffFiles, this.fileHash); + const indexForInline = utils.findIndexInInlineLines(diffFile.highlighted_diff_lines, { + oldLineNumber, + newLineNumber, + }); + const prevLine = diffFile.highlighted_diff_lines[indexForInline - 2]; + return (prevLine && prevLine.new_line) || 0; + }, + callLoadMoreLines( + endpoint, + params, + lineNumbers, + fileHash, + isExpandDown = false, + nextLineNumbers = {}, + ) { + this.loadMoreLines({ endpoint, params, lineNumbers, fileHash, isExpandDown, nextLineNumbers }) + .then(() => { + this.isRequesting = false; + }) + .catch(() => { + createFlash(s__('Diffs|Something went wrong while fetching diff lines.')); + this.isRequesting = false; + }); + }, + handleExpandLines(type = EXPAND_ALL) { + if (this.isRequesting) { + return; + } + + this.isRequesting = true; + const endpoint = this.contextLinesPath; + const { fileHash } = this; + const view = this.diffViewType; + const oldLineNumber = this.line.meta_data.old_pos || 0; + const newLineNumber = this.line.meta_data.new_pos || 0; + const offset = newLineNumber - oldLineNumber; + + const expandOptions = { endpoint, fileHash, view, oldLineNumber, newLineNumber, offset }; + + if (type === EXPAND_UP) { + this.handleExpandUpLines(expandOptions); + } else if (type === EXPAND_DOWN) { + this.handleExpandDownLines(expandOptions); + } else { + this.handleExpandAllLines(expandOptions); + } + }, + handleExpandUpLines(expandOptions = EXPAND_ALL) { + const { endpoint, fileHash, view, oldLineNumber, newLineNumber, offset } = expandOptions; + + const bottom = this.isBottom; + const lineNumber = newLineNumber - 1; + const to = lineNumber; + let since = lineNumber - UNFOLD_COUNT; + let unfold = true; + + const prevLineNumber = this.getPrevLineNumber(oldLineNumber, newLineNumber); + if (since <= prevLineNumber + 1) { + since = prevLineNumber + 1; + unfold = false; + } + + const params = { since, to, bottom, offset, unfold, view }; + const lineNumbers = { oldLineNumber, newLineNumber }; + this.callLoadMoreLines(endpoint, params, lineNumbers, fileHash); + }, + handleExpandDownLines(expandOptions) { + const { + endpoint, + fileHash, + view, + oldLineNumber: metaOldPos, + newLineNumber: metaNewPos, + offset, + } = expandOptions; + + const bottom = true; + const nextLineNumbers = { + old_line: metaOldPos, + new_line: metaNewPos, + }; + + let unfold = true; + let isExpandDown = false; + let oldLineNumber = metaOldPos; + let newLineNumber = metaNewPos; + let lineNumber = metaNewPos + 1; + let since = lineNumber; + let to = lineNumber + UNFOLD_COUNT; + + if (!this.isBottom) { + const prevLineNumber = this.getPrevLineNumber(oldLineNumber, newLineNumber); + + isExpandDown = true; + oldLineNumber = prevLineNumber - offset; + newLineNumber = prevLineNumber; + lineNumber = prevLineNumber + 1; + since = lineNumber; + to = lineNumber + UNFOLD_COUNT; + + if (to >= metaNewPos) { + to = metaNewPos - 1; + unfold = false; + } + } + + const params = { since, to, bottom, offset, unfold, view }; + const lineNumbers = { oldLineNumber, newLineNumber }; + this.callLoadMoreLines( + endpoint, + params, + lineNumbers, + fileHash, + isExpandDown, + nextLineNumbers, + ); + }, + handleExpandAllLines(expandOptions) { + const { endpoint, fileHash, view, oldLineNumber, newLineNumber, offset } = expandOptions; + const bottom = this.isBottom; + const unfold = false; + let since; + let to; + + if (this.isTop) { + since = 1; + to = newLineNumber - 1; + } else if (bottom) { + since = newLineNumber + 1; + to = -1; + } else { + const prevLineNumber = this.getPrevLineNumber(oldLineNumber, newLineNumber); + since = prevLineNumber + 1; + to = newLineNumber - 1; + } + + const params = { since, to, bottom, offset, unfold, view }; + const lineNumbers = { oldLineNumber, newLineNumber }; + this.callLoadMoreLines(endpoint, params, lineNumbers, fileHash); + }, + }, +}; +</script> + +<template> + <td :colspan="colspan" class="text-center"> + <div class="content js-line-expansion-content"> + <a + v-if="canExpandUp" + v-tooltip + class="cursor-pointer js-unfold unfold-icon d-inline-block pt-2 pb-2" + data-placement="top" + data-container="body" + :title="__('Expand up')" + @click="handleExpandLines(EXPAND_UP)" + > + <icon :size="12" name="expand-up" aria-hidden="true" /> + </a> + <a class="mx-2 cursor-pointer js-unfold-all" @click="handleExpandLines()"> + <span>{{ s__('Diffs|Show all lines') }}</span> + </a> + <a + v-if="canExpandDown" + v-tooltip + class="cursor-pointer js-unfold-down has-tooltip unfold-icon d-inline-block pt-2 pb-2" + data-placement="top" + data-container="body" + :title="__('Expand down')" + @click="handleExpandLines(EXPAND_DOWN)" + > + <icon :size="12" name="expand-down" aria-hidden="true" /> + </a> + </div> + </td> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 63350fafefa..2514274224d 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -67,6 +67,18 @@ export default { errorMessage() { return this.file.viewer.error_message; }, + forkMessage() { + return sprintf( + __( + "You're not allowed to %{tag_start}edit%{tag_end} files in this project directly. Please fork this project, make your changes there, and submit a merge request.", + ), + { + tag_start: '<span class="js-file-fork-suggestion-section-action">', + tag_end: '</span>', + }, + false, + ); + }, }, watch: { isCollapsed: function fileCollapsedWatch(newVal, oldVal) { @@ -150,12 +162,7 @@ export default { /> <div v-if="forkMessageVisible" class="js-file-fork-suggestion-section file-fork-suggestion"> - <span class="file-fork-suggestion-note"> - {{ sprintf(__("You're not allowed to %{tag_start}edit%{tag_end} files in this project - directly. Please fork this project, make your changes there, and submit a merge request."), - { tag_start: '<span class="js-file-fork-suggestion-section-action">', tag_end: '</span>' }) - }} - </span> + <span class="file-fork-suggestion-note" v-html="forkMessage"></span> <a :href="file.fork_path" class="js-fork-suggestion-button btn btn-grouped btn-inverted btn-success" diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index eb9f1465945..69ec6ab8600 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -130,7 +130,7 @@ export default { return `\`${this.diffFile.file_path}\``; }, isFileRenamed() { - return this.diffFile.viewer.name === diffViewerModes.renamed; + return this.diffFile.renamed_file; }, isModeChanged() { return this.diffFile.viewer.name === diffViewerModes.mode_changed; @@ -151,7 +151,11 @@ export default { stickyMonitor(this.$refs.header, contentTop() - fileHeaderHeight - 1, false); }, methods: { - ...mapActions('diffs', ['toggleFileDiscussions', 'toggleFullDiff']), + ...mapActions('diffs', [ + 'toggleFileDiscussions', + 'toggleFileDiscussionWrappers', + 'toggleFullDiff', + ]), handleToggleFile(e, checkTarget) { if ( !checkTarget || @@ -165,7 +169,7 @@ export default { this.$emit('showForkMessage'); }, handleToggleDiscussions() { - this.toggleFileDiscussions(this.diffFile); + this.toggleFileDiscussionWrappers(this.diffFile); }, handleFileNameClick(e) { const isLinkToOtherPage = @@ -259,6 +263,7 @@ export default { :disabled="!diffHasDiscussions(diffFile)" :class="{ active: hasExpandedDiscussions }" class="js-btn-vue-toggle-comments btn" + data-qa-selector="toggle_comments_button" type="button" @click="handleToggleDiscussions" > diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue index e28909b7be3..7ede7a4f430 100644 --- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue +++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue @@ -1,7 +1,7 @@ <script> -import { mapActions } from 'vuex'; +import { n__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; -import { pluralize, truncate } from '~/lib/utils/text_utility'; +import { truncate } from '~/lib/utils/text_utility'; import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; import { GlTooltipDirective } from '@gitlab/ui'; import { COUNT_OF_AVATARS_IN_GUTTER, LENGTH_OF_AVATAR_TOOLTIP } from '../constants'; @@ -19,11 +19,13 @@ export default { type: Array, required: true, }, + discussionsExpanded: { + type: Boolean, + required: false, + default: false, + }, }, computed: { - discussionsExpanded() { - return this.discussions.every(discussion => discussion.expanded); - }, allDiscussions() { return this.discussions.reduce((acc, note) => acc.concat(note.notes), []); }, @@ -41,30 +43,18 @@ export default { return ''; } - return pluralize(`${this.moreCount} more comment`, this.moreCount); + return n__('%d more comment', '%d more comments', this.moreCount); }, }, methods: { - ...mapActions(['toggleDiscussion']), getTooltipText(noteData) { let { note } = noteData; - if (note.length > LENGTH_OF_AVATAR_TOOLTIP) { note = truncate(note, LENGTH_OF_AVATAR_TOOLTIP); } return `${noteData.author.name}: ${note}`; }, - toggleDiscussions() { - const forceExpanded = this.discussions.some(discussion => !discussion.expanded); - - this.discussions.forEach(discussion => { - this.toggleDiscussion({ - discussionId: discussion.id, - forceExpanded, - }); - }); - }, }, }; </script> @@ -76,7 +66,7 @@ export default { type="button" :aria-label="__('Show comments')" class="diff-notes-collapse js-diff-comment-avatar js-diff-comment-button" - @click="toggleDiscussions" + @click="$emit('toggleLineDiscussions')" > <icon :size="12" name="collapse" /> </button> @@ -87,7 +77,7 @@ export default { :img-src="note.author.avatar_url" :tooltip-text="getTooltipText(note)" class="diff-comment-avatar js-diff-comment-avatar" - @click.native="toggleDiscussions" + @click.native="$emit('toggleLineDiscussions')" /> <span v-if="moreText" @@ -97,7 +87,7 @@ export default { data-container="body" data-placement="top" role="button" - @click="toggleDiscussions" + @click="$emit('toggleLineDiscussions')" >+{{ moreCount }}</span > </template> diff --git a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue index 1281f9b17ef..434d554d148 100644 --- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue +++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue @@ -1,11 +1,8 @@ <script> -import createFlash from '~/flash'; -import { s__ } from '~/locale'; import { mapState, mapGetters, mapActions } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; import DiffGutterAvatars from './diff_gutter_avatars.vue'; -import { LINE_POSITION_RIGHT, UNFOLD_COUNT } from '../constants'; -import * as utils from '../store/utils'; +import { LINE_POSITION_RIGHT } from '../constants'; export default { components: { @@ -105,86 +102,46 @@ export default { }, }, methods: { - ...mapActions('diffs', ['loadMoreLines', 'showCommentForm', 'setHighlightedRow']), + ...mapActions('diffs', [ + 'loadMoreLines', + 'showCommentForm', + 'setHighlightedRow', + 'toggleLineDiscussions', + 'toggleLineDiscussionWrappers', + ]), handleCommentButton() { this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash }); }, - handleLoadMoreLines() { - if (this.isRequesting) { - return; - } - - this.isRequesting = true; - const endpoint = this.contextLinesPath; - const oldLineNumber = this.line.meta_data.old_pos || 0; - const newLineNumber = this.line.meta_data.new_pos || 0; - const offset = newLineNumber - oldLineNumber; - const bottom = this.isBottom; - const { fileHash } = this; - const view = this.diffViewType; - let unfold = true; - let lineNumber = newLineNumber - 1; - let since = lineNumber - UNFOLD_COUNT; - let to = lineNumber; - - if (bottom) { - lineNumber = newLineNumber + 1; - since = lineNumber; - to = lineNumber + UNFOLD_COUNT; - } else { - const diffFile = utils.findDiffFile(this.diffFiles, this.fileHash); - const indexForInline = utils.findIndexInInlineLines(diffFile.highlighted_diff_lines, { - oldLineNumber, - newLineNumber, - }); - const prevLine = diffFile.highlighted_diff_lines[indexForInline - 2]; - const prevLineNumber = (prevLine && prevLine.new_line) || 0; - - if (since <= prevLineNumber + 1) { - since = prevLineNumber + 1; - unfold = false; - } - } - - const params = { since, to, bottom, offset, unfold, view }; - const lineNumbers = { oldLineNumber, newLineNumber }; - this.loadMoreLines({ endpoint, params, lineNumbers, fileHash }) - .then(() => { - this.isRequesting = false; - }) - .catch(() => { - createFlash(s__('Diffs|Something went wrong while fetching diff lines.')); - this.isRequesting = false; - }); - }, }, }; </script> <template> <div> - <span v-if="isMatchLine" class="context-cell" role="button" @click="handleLoadMoreLines" - >...</span + <button + v-if="shouldRenderCommentButton" + v-show="shouldShowCommentButton" + type="button" + class="add-diff-note js-add-diff-note-button qa-diff-comment" + title="Add a comment to this line" + @click="handleCommentButton" + > + <icon :size="12" name="comment" /> + </button> + <a + v-if="lineNumber" + :data-linenumber="lineNumber" + :href="lineHref" + @click="setHighlightedRow(lineCode)" > - <template v-else> - <button - v-if="shouldRenderCommentButton" - v-show="shouldShowCommentButton" - type="button" - class="add-diff-note js-add-diff-note-button qa-diff-comment" - title="Add a comment to this line" - @click="handleCommentButton" - > - <icon :size="12" name="comment" /> - </button> - <a - v-if="lineNumber" - :data-linenumber="lineNumber" - :href="lineHref" - @click="setHighlightedRow(lineCode)" - > - </a> - <diff-gutter-avatars v-if="shouldShowAvatarsOnGutter" :discussions="line.discussions" /> - </template> + </a> + <diff-gutter-avatars + v-if="shouldShowAvatarsOnGutter" + :discussions="line.discussions" + :discussions-expanded="line.discussionsExpanded" + @toggleLineDiscussions=" + toggleLineDiscussions({ lineCode, fileHash, expanded: !line.discussionsExpanded }) + " + /> </div> </template> diff --git a/app/assets/javascripts/diffs/components/hidden_files_warning.vue b/app/assets/javascripts/diffs/components/hidden_files_warning.vue index 119e139de21..035c2b3b11e 100644 --- a/app/assets/javascripts/diffs/components/hidden_files_warning.vue +++ b/app/assets/javascripts/diffs/components/hidden_files_warning.vue @@ -1,4 +1,5 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ export default { props: { total: { 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 1faa0493e79..a06dbd70ac5 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue @@ -1,11 +1,14 @@ <script> -import diffDiscussions from './diff_discussions.vue'; -import diffLineNoteForm from './diff_line_note_form.vue'; +import { mapActions } from 'vuex'; +import DiffDiscussions from './diff_discussions.vue'; +import DiffLineNoteForm from './diff_line_note_form.vue'; +import DiffDiscussionReply from './diff_discussion_reply.vue'; export default { components: { - diffDiscussions, - diffLineNoteForm, + DiffDiscussions, + DiffLineNoteForm, + DiffDiscussionReply, }, props: { line: { @@ -21,6 +24,11 @@ export default { required: false, default: '', }, + hasDraft: { + type: Boolean, + required: false, + default: false, + }, }, computed: { className() { @@ -32,10 +40,12 @@ export default { if (!this.line.discussions || !this.line.discussions.length) { return false; } - - return this.line.discussions.every(discussion => discussion.expanded); + return this.line.discussionsExpanded; }, }, + methods: { + ...mapActions('diffs', ['showCommentForm']), + }, }; </script> @@ -49,13 +59,23 @@ export default { :discussions="line.discussions" :help-page-path="helpPagePath" /> - <diff-line-note-form - v-if="line.hasForm" - :diff-file-hash="diffFileHash" - :line="line" - :note-target-line="line" - :help-page-path="helpPagePath" - /> + <diff-discussion-reply + v-if="!hasDraft" + :has-form="line.hasForm" + :render-reply-placeholder="Boolean(line.discussions.length)" + @showNewDiscussionForm=" + showCommentForm({ lineCode: line.line_code, fileHash: diffFileHash }) + " + > + <template #form> + <diff-line-note-form + :diff-file-hash="diffFileHash" + :line="line" + :note-target-line="line" + :help-page-path="helpPagePath" + /> + </template> + </diff-discussion-reply> </div> </td> </tr> diff --git a/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue b/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue new file mode 100644 index 00000000000..6e732727f42 --- /dev/null +++ b/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue @@ -0,0 +1,53 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import DiffExpansionCell from './diff_expansion_cell.vue'; +import { MATCH_LINE_TYPE } from '../constants'; + +export default { + components: { + Icon, + DiffExpansionCell, + }, + props: { + fileHash: { + type: String, + required: true, + }, + contextLinesPath: { + type: String, + required: true, + }, + line: { + type: Object, + required: true, + }, + isTop: { + type: Boolean, + required: false, + default: false, + }, + isBottom: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + isMatchLine() { + return this.line.type === MATCH_LINE_TYPE; + }, + }, +}; +</script> + +<template> + <tr v-if="isMatchLine" class="line_expansion match"> + <diff-expansion-cell + :file-hash="fileHash" + :context-lines-path="contextLinesPath" + :line="line" + :is-top="isTop" + :is-bottom="isBottom" + /> + </tr> +</template> diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue index 2d5262baeec..55a8df43c62 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue @@ -2,6 +2,7 @@ import { mapActions, mapState } from 'vuex'; import DiffTableCell from './diff_table_cell.vue'; import { + MATCH_LINE_TYPE, NEW_LINE_TYPE, OLD_LINE_TYPE, CONTEXT_LINE_TYPE, @@ -58,6 +59,9 @@ export default { inlineRowId() { return this.line.line_code || `${this.fileHash}_${this.line.old_line}_${this.line.new_line}`; }, + isMatchLine() { + return this.line.type === MATCH_LINE_TYPE; + }, }, created() { this.newLineType = NEW_LINE_TYPE; @@ -81,6 +85,7 @@ export default { <template> <tr + v-if="!isMatchLine" :id="inlineRowId" :class="classNameMap" class="line_holder" diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue index 8c76a555b62..aee01409db7 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_view.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue @@ -3,6 +3,7 @@ import { mapGetters } from 'vuex'; import draftCommentsMixin from 'ee_else_ce/diffs/mixins/draft_comments'; import inlineDiffTableRow from './inline_diff_table_row.vue'; import inlineDiffCommentRow from './inline_diff_comment_row.vue'; +import inlineDiffExpansionRow from './inline_diff_expansion_row.vue'; export default { components: { @@ -10,6 +11,7 @@ export default { inlineDiffTableRow, InlineDraftCommentRow: () => import('ee_component/batch_comments/components/inline_draft_comment_row.vue'), + inlineDiffExpansionRow, }, mixins: [draftCommentsMixin], props: { @@ -43,10 +45,24 @@ export default { :data-commit-id="commitId" class="code diff-wrap-lines js-syntax-highlight text-file js-diff-inline-view" > + <!-- Need to insert an empty row to solve "table-layout:fixed" equal width when expansion row is the first line --> + <tr> + <td style="width: 50px;"></td> + <td style="width: 50px;"></td> + <td></td> + </tr> <tbody> <template v-for="(line, index) in diffLines"> + <inline-diff-expansion-row + :key="`expand-${index}`" + :file-hash="diffFile.file_hash" + :context-lines-path="diffFile.context_lines_path" + :line="line" + :is-top="index === 0" + :is-bottom="index + 1 === diffLinesLength" + /> <inline-diff-table-row - :key="line.line_code" + :key="`${line.line_code || index}`" :file-hash="diffFile.file_hash" :context-lines-path="diffFile.context_lines_path" :line="line" @@ -57,6 +73,7 @@ export default { :diff-file-hash="diffFile.file_hash" :line="line" :help-page-path="helpPagePath" + :has-draft="shouldRenderDraftRow(diffFile.file_hash, line) || false" /> <inline-draft-comment-row v-if="shouldRenderDraftRow(diffFile.file_hash, line)" 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 d2e54edca85..65b41b0e456 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue @@ -1,11 +1,14 @@ <script> -import diffDiscussions from './diff_discussions.vue'; -import diffLineNoteForm from './diff_line_note_form.vue'; +import { mapActions } from 'vuex'; +import DiffDiscussions from './diff_discussions.vue'; +import DiffLineNoteForm from './diff_line_note_form.vue'; +import DiffDiscussionReply from './diff_discussion_reply.vue'; export default { components: { - diffDiscussions, - diffLineNoteForm, + DiffDiscussions, + DiffLineNoteForm, + DiffDiscussionReply, }, props: { line: { @@ -25,28 +28,44 @@ export default { required: false, default: '', }, + hasDraftLeft: { + type: Boolean, + required: false, + default: false, + }, + hasDraftRight: { + type: Boolean, + required: false, + default: false, + }, }, computed: { hasExpandedDiscussionOnLeft() { return this.line.left && this.line.left.discussions.length - ? this.line.left.discussions.every(discussion => discussion.expanded) + ? this.line.left.discussionsExpanded : false; }, hasExpandedDiscussionOnRight() { return this.line.right && this.line.right.discussions.length - ? this.line.right.discussions.every(discussion => discussion.expanded) + ? this.line.right.discussionsExpanded : false; }, hasAnyExpandedDiscussion() { return this.hasExpandedDiscussionOnLeft || this.hasExpandedDiscussionOnRight; }, shouldRenderDiscussionsOnLeft() { - return this.line.left && this.line.left.discussions && this.hasExpandedDiscussionOnLeft; + return ( + this.line.left && + this.line.left.discussions && + this.line.left.discussions.length && + this.hasExpandedDiscussionOnLeft + ); }, shouldRenderDiscussionsOnRight() { return ( this.line.right && this.line.right.discussions && + this.line.right.discussions.length && this.hasExpandedDiscussionOnRight && this.line.right.type ); @@ -81,6 +100,22 @@ export default { return hasCommentFormOnLeft || hasCommentFormOnRight; }, + shouldRenderReplyPlaceholderOnLeft() { + return Boolean( + this.line.left && this.line.left.discussions && this.line.left.discussions.length, + ); + }, + shouldRenderReplyPlaceholderOnRight() { + return Boolean( + this.line.right && this.line.right.discussions && this.line.right.discussions.length, + ); + }, + }, + methods: { + ...mapActions('diffs', ['showCommentForm']), + showNewDiscussionForm() { + this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.diffFileHash }); + }, }, }; </script> @@ -90,37 +125,51 @@ export default { <td class="notes-content parallel old" colspan="2"> <div v-if="shouldRenderDiscussionsOnLeft" class="content"> <diff-discussions - v-if="line.left.discussions.length" :discussions="line.left.discussions" :line="line.left" :help-page-path="helpPagePath" /> </div> - <diff-line-note-form - v-if="showLeftSideCommentForm" - :diff-file-hash="diffFileHash" - :line="line.left" - :note-target-line="line.left" - :help-page-path="helpPagePath" - line-position="left" - /> + <diff-discussion-reply + v-if="!hasDraftLeft" + :has-form="showLeftSideCommentForm" + :render-reply-placeholder="shouldRenderReplyPlaceholderOnLeft" + @showNewDiscussionForm="showNewDiscussionForm" + > + <template #form> + <diff-line-note-form + :diff-file-hash="diffFileHash" + :line="line.left" + :note-target-line="line.left" + :help-page-path="helpPagePath" + line-position="left" + /> + </template> + </diff-discussion-reply> </td> <td class="notes-content parallel new" colspan="2"> <div v-if="shouldRenderDiscussionsOnRight" class="content"> <diff-discussions - v-if="line.right.discussions.length" :discussions="line.right.discussions" :line="line.right" :help-page-path="helpPagePath" /> </div> - <diff-line-note-form - v-if="showRightSideCommentForm" - :diff-file-hash="diffFileHash" - :line="line.right" - :note-target-line="line.right" - line-position="right" - /> + <diff-discussion-reply + v-if="!hasDraftRight" + :has-form="showRightSideCommentForm" + :render-reply-placeholder="shouldRenderReplyPlaceholderOnRight" + @showNewDiscussionForm="showNewDiscussionForm" + > + <template #form> + <diff-line-note-form + :diff-file-hash="diffFileHash" + :line="line.right" + :note-target-line="line.right" + line-position="right" + /> + </template> + </diff-discussion-reply> </td> </tr> </template> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_expansion_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_expansion_row.vue new file mode 100644 index 00000000000..c1b30eab199 --- /dev/null +++ b/app/assets/javascripts/diffs/components/parallel_diff_expansion_row.vue @@ -0,0 +1,56 @@ +<script> +import { MATCH_LINE_TYPE } from '../constants'; +import DiffExpansionCell from './diff_expansion_cell.vue'; + +export default { + components: { + DiffExpansionCell, + }, + props: { + fileHash: { + type: String, + required: true, + }, + contextLinesPath: { + type: String, + required: true, + }, + line: { + type: Object, + required: true, + }, + isTop: { + type: Boolean, + required: false, + default: false, + }, + isBottom: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + isMatchLineLeft() { + return this.line.left && this.line.left.type === MATCH_LINE_TYPE; + }, + isMatchLineRight() { + return this.line.right && this.line.right.type === MATCH_LINE_TYPE; + }, + }, +}; +</script> +<template> + <tr class="line_expansion match"> + <template v-if="isMatchLineLeft || isMatchLineRight"> + <diff-expansion-cell + :file-hash="fileHash" + :context-lines-path="contextLinesPath" + :line="line.left" + :is-top="isTop" + :is-bottom="isBottom" + :colspan="4" + /> + </template> + </tr> +</template> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue index c60246bf8ef..4c95d618b0f 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue @@ -3,6 +3,7 @@ import { mapActions, mapState } from 'vuex'; import $ from 'jquery'; import DiffTableCell from './diff_table_cell.vue'; import { + MATCH_LINE_TYPE, NEW_LINE_TYPE, OLD_LINE_TYPE, CONTEXT_LINE_TYPE, @@ -75,6 +76,12 @@ export default { }, ]; }, + isMatchLineLeft() { + return this.line.left && this.line.left.type === MATCH_LINE_TYPE; + }, + isMatchLineRight() { + return this.line.right && this.line.right.type === MATCH_LINE_TYPE; + }, }, created() { this.newLineType = NEW_LINE_TYPE; @@ -122,7 +129,7 @@ export default { @mouseover="handleMouseMove" @mouseout="handleMouseMove" > - <template v-if="line.left"> + <template v-if="line.left && !isMatchLineLeft"> <diff-table-cell :file-hash="fileHash" :context-lines-path="contextLinesPath" @@ -148,7 +155,7 @@ export default { <td class="diff-line-num old_line empty-cell"></td> <td class="line_content parallel left-side empty-cell"></td> </template> - <template v-if="line.right"> + <template v-if="line.right && !isMatchLineRight"> <diff-table-cell :file-hash="fileHash" :context-lines-path="contextLinesPath" diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue index 41a80d99850..d400eb2c586 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue @@ -3,9 +3,11 @@ import { mapGetters } from 'vuex'; import draftCommentsMixin from 'ee_else_ce/diffs/mixins/draft_comments'; import parallelDiffTableRow from './parallel_diff_table_row.vue'; import parallelDiffCommentRow from './parallel_diff_comment_row.vue'; +import parallelDiffExpansionRow from './parallel_diff_expansion_row.vue'; export default { components: { + parallelDiffExpansionRow, parallelDiffTableRow, parallelDiffCommentRow, ParallelDraftCommentRow: () => @@ -43,8 +45,23 @@ export default { :data-commit-id="commitId" class="code diff-wrap-lines js-syntax-highlight text-file" > + <!-- Need to insert an empty row to solve "table-layout:fixed" equal width when expansion row is the first line --> + <tr> + <td style="width: 50px;"></td> + <td></td> + <td style="width: 50px;"></td> + <td></td> + </tr> <tbody> <template v-for="(line, index) in diffLines"> + <parallel-diff-expansion-row + :key="`expand-${index}`" + :file-hash="diffFile.file_hash" + :context-lines-path="diffFile.context_lines_path" + :line="line" + :is-top="index === 0" + :is-bottom="index + 1 === diffLinesLength" + /> <parallel-diff-table-row :key="line.line_code" :file-hash="diffFile.file_hash" @@ -58,6 +75,8 @@ export default { :diff-file-hash="diffFile.file_hash" :line-index="index" :help-page-path="helpPagePath" + :has-draft-left="hasParallelDraftLeft(diffFile.file_hash, line) || false" + :has-draft-right="hasParallelDraftRight(diffFile.file_hash, line) || false" /> <parallel-draft-comment-row v-if="shouldRenderParallelDraftRow(diffFile.file_hash, line)" diff --git a/app/assets/javascripts/diffs/mixins/draft_comments.js b/app/assets/javascripts/diffs/mixins/draft_comments.js index dfb71bf38ce..b6c9b132aeb 100644 --- a/app/assets/javascripts/diffs/mixins/draft_comments.js +++ b/app/assets/javascripts/diffs/mixins/draft_comments.js @@ -6,5 +6,7 @@ export default { imageDiscussions() { return this.diffFile.discussions; }, + hasParallelDraftLeft: () => () => false, + hasParallelDraftRight: () => () => false, }, }; diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 88d7b4bba63..6695d9fe96c 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -12,6 +12,7 @@ import { getNoteFormData, convertExpandLines, idleCallback, + allDiscussionWrappersExpanded, } from './utils'; import * as types from './mutation_types'; import { @@ -79,6 +80,7 @@ export const assignDiscussionsToDiff = ( discussions = rootState.notes.discussions, ) => { const diffPositionByLineCode = getDiffPositionByLineCode(state.diffFiles); + const hash = getLocationHash(); discussions .filter(discussion => discussion.diff_discussion) @@ -86,6 +88,7 @@ export const assignDiscussionsToDiff = ( commit(types.SET_LINE_DISCUSSIONS_FOR_FILE, { discussion, diffPositionByLineCode, + hash, }); }); @@ -99,10 +102,14 @@ export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => { commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash: file_hash, lineCode: line_code, id }); }; +export const toggleLineDiscussions = ({ commit }, options) => { + commit(types.TOGGLE_LINE_DISCUSSIONS, options); +}; + export const renderFileForDiscussionId = ({ commit, rootState, state }, discussionId) => { const discussion = rootState.notes.discussions.find(d => d.id === discussionId); - if (discussion) { + if (discussion && discussion.diff_file) { const file = state.diffFiles.find(f => f.file_hash === discussion.diff_file.file_hash); if (file) { @@ -176,7 +183,7 @@ export const cancelCommentForm = ({ commit }, { lineCode, fileHash }) => { }; export const loadMoreLines = ({ commit }, options) => { - const { endpoint, params, lineNumbers, fileHash } = options; + const { endpoint, params, lineNumbers, fileHash, isExpandDown, nextLineNumbers } = options; params.from_merge_request = true; @@ -188,6 +195,8 @@ export const loadMoreLines = ({ commit }, options) => { contextLines, params, fileHash, + isExpandDown, + nextLineNumbers, }); }); }; @@ -257,6 +266,31 @@ export const toggleFileDiscussions = ({ getters, dispatch }, diff) => { }); }; +export const toggleFileDiscussionWrappers = ({ commit }, diff) => { + const discussionWrappersExpanded = allDiscussionWrappersExpanded(diff); + let linesWithDiscussions; + if (diff.highlighted_diff_lines) { + linesWithDiscussions = diff.highlighted_diff_lines.filter(line => line.discussions.length); + } + if (diff.parallel_diff_lines) { + linesWithDiscussions = diff.parallel_diff_lines.filter( + line => + (line.left && line.left.discussions.length) || + (line.right && line.right.discussions.length), + ); + } + + if (linesWithDiscussions.length) { + linesWithDiscussions.forEach(line => { + commit(types.TOGGLE_LINE_DISCUSSIONS, { + fileHash: diff.file_hash, + lineCode: line.line_code, + expanded: !discussionWrappersExpanded, + }); + }); + } +}; + export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => { const postData = getNoteFormData({ commit: state.commit, @@ -267,7 +301,7 @@ export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => { return dispatch('saveNote', postData, { root: true }) .then(result => dispatch('updateDiscussion', result.discussion, { root: true })) .then(discussion => dispatch('assignDiscussionsToDiff', [discussion])) - .then(() => dispatch('updateResolvableDiscussonsCounts', null, { root: true })) + .then(() => dispatch('updateResolvableDiscussionsCounts', null, { root: true })) .then(() => dispatch('closeDiffFileCommentForm', formData.diffFile.file_hash)) .catch(() => createFlash(s__('MergeRequests|Saving the comment failed'))); }; diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index 8d6111da500..9db56331faa 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -35,3 +35,5 @@ export const ADD_CURRENT_VIEW_DIFF_FILE_LINES = 'ADD_CURRENT_VIEW_DIFF_FILE_LINE export const TOGGLE_DIFF_FILE_RENDERING_MORE = 'TOGGLE_DIFF_FILE_RENDERING_MORE'; export const SET_SHOW_SUGGEST_POPOVER = 'SET_SHOW_SUGGEST_POPOVER'; + +export const TOGGLE_LINE_DISCUSSIONS = 'TOGGLE_LINE_DISCUSSIONS'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 00181a63c43..a6915a46c00 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -6,6 +6,7 @@ import { addContextLines, prepareDiffData, isDiscussionApplicableToLine, + updateLineInFile, } from './utils'; import * as types from './mutation_types'; @@ -70,18 +71,30 @@ export default { }, [types.ADD_CONTEXT_LINES](state, options) { - const { lineNumbers, contextLines, fileHash } = options; + const { lineNumbers, contextLines, fileHash, isExpandDown, nextLineNumbers } = options; const { bottom } = options.params; const diffFile = findDiffFile(state.diffFiles, fileHash); removeMatchLine(diffFile, lineNumbers, bottom); - const lines = addLineReferences(contextLines, lineNumbers, bottom).map(line => ({ - ...line, - line_code: line.line_code || `${fileHash}_${line.old_line}_${line.new_line}`, - discussions: line.discussions || [], - hasForm: false, - })); + const lines = addLineReferences( + contextLines, + lineNumbers, + bottom, + isExpandDown, + nextLineNumbers, + ).map(line => { + const lineCode = + line.type === 'match' + ? `${fileHash}_${line.meta_data.old_pos}_${line.meta_data.new_pos}_match` + : line.line_code || `${fileHash}_${line.old_line}_${line.new_line}`; + return { + ...line, + line_code: lineCode, + discussions: line.discussions || [], + hasForm: false, + }; + }); addContextLines({ inlineLines: diffFile.highlighted_diff_lines, @@ -89,6 +102,7 @@ export default { contextLines: lines, bottom, lineNumbers, + isExpandDown, }); }, @@ -109,7 +123,7 @@ export default { })); }, - [types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode }) { + [types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode, hash }) { const { latestDiff } = state; const discussionLineCode = discussion.line_code; @@ -130,13 +144,27 @@ export default { : [], }); + const setDiscussionsExpanded = line => { + const isLineNoteTargeted = line.discussions.some( + disc => disc.notes && disc.notes.find(note => hash === `note_${note.id}`), + ); + + return { + ...line, + discussionsExpanded: + line.discussions && line.discussions.length + ? line.discussions.some(disc => !disc.resolved) || isLineNoteTargeted + : false, + }; + }; + 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 => - lineCheck(line) ? mapDiscussions(line) : line, + setDiscussionsExpanded(lineCheck(line) ? mapDiscussions(line) : line), ); } @@ -148,8 +176,10 @@ export default { if (left || right) { return { ...line, - left: line.left ? mapDiscussions(line.left) : null, - right: line.right ? mapDiscussions(line.right, () => !left) : null, + left: line.left ? setDiscussionsExpanded(mapDiscussions(line.left)) : null, + right: line.right + ? setDiscussionsExpanded(mapDiscussions(line.right, () => !left)) + : null, }; } @@ -173,32 +203,11 @@ export default { [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) { - const targetLine = selectedFile.parallel_diff_lines.find( - line => - (line.left && line.left.line_code === lineCode) || - (line.right && line.right.line_code === lineCode), - ); - if (targetLine) { - const side = targetLine.left && targetLine.left.line_code === lineCode ? 'left' : 'right'; - - Object.assign(targetLine[side], { - discussions: targetLine[side].discussions.filter(discussion => discussion.notes.length), - }); - } - } - - if (selectedFile.highlighted_diff_lines) { - const targetInlineLine = selectedFile.highlighted_diff_lines.find( - line => line.line_code === lineCode, - ); - - if (targetInlineLine) { - Object.assign(targetInlineLine, { - discussions: targetInlineLine.discussions.filter(discussion => discussion.notes.length), - }); - } - } + updateLineInFile(selectedFile, lineCode, line => + Object.assign(line, { + discussions: line.discussions.filter(discussion => discussion.notes.length), + }), + ); if (selectedFile.discussions && selectedFile.discussions.length) { selectedFile.discussions = selectedFile.discussions.filter( @@ -207,6 +216,15 @@ export default { } } }, + + [types.TOGGLE_LINE_DISCUSSIONS](state, { fileHash, lineCode, expanded }) { + const selectedFile = state.diffFiles.find(f => f.file_hash === fileHash); + + updateLineInFile(selectedFile, lineCode, line => + Object.assign(line, { discussionsExpanded: expanded }), + ); + }, + [types.TOGGLE_FOLDER_OPEN](state, path) { state.treeEntries[path].opened = !state.treeEntries[path].opened; }, diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 71956255eef..d46bdea9b50 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -121,7 +121,7 @@ export function removeMatchLine(diffFile, lineNumbers, bottom) { diffFile.parallel_diff_lines.splice(indexForParallel + factor, 1); } -export function addLineReferences(lines, lineNumbers, bottom) { +export function addLineReferences(lines, lineNumbers, bottom, isExpandDown, nextLineNumbers) { const { oldLineNumber, newLineNumber } = lineNumbers; const lineCount = lines.length; let matchLineIndex = -1; @@ -135,15 +135,20 @@ export function addLineReferences(lines, lineNumbers, bottom) { new_line: bottom ? newLineNumber + index + 1 : newLineNumber + index - lineCount, }); } - return l; }); if (matchLineIndex > -1) { const line = linesWithNumbers[matchLineIndex]; - const targetLine = bottom - ? linesWithNumbers[matchLineIndex - 1] - : linesWithNumbers[matchLineIndex + 1]; + let targetLine; + + if (isExpandDown) { + targetLine = nextLineNumbers; + } else if (bottom) { + targetLine = linesWithNumbers[matchLineIndex - 1]; + } else { + targetLine = linesWithNumbers[matchLineIndex + 1]; + } Object.assign(line, { meta_data: { @@ -152,26 +157,27 @@ export function addLineReferences(lines, lineNumbers, bottom) { }, }); } - return linesWithNumbers; } export function addContextLines(options) { - const { inlineLines, parallelLines, contextLines, lineNumbers } = options; + const { inlineLines, parallelLines, contextLines, lineNumbers, isExpandDown } = options; const normalizedParallelLines = contextLines.map(line => ({ left: line, right: line, line_code: line.line_code, })); + const factor = isExpandDown ? 1 : 0; - if (options.bottom) { + if (!isExpandDown && options.bottom) { inlineLines.push(...contextLines); parallelLines.push(...normalizedParallelLines); } else { const inlineIndex = findIndexInInlineLines(inlineLines, lineNumbers); const parallelIndex = findIndexInParallelLines(parallelLines, lineNumbers); - inlineLines.splice(inlineIndex, 0, ...contextLines); - parallelLines.splice(parallelIndex, 0, ...normalizedParallelLines); + + inlineLines.splice(inlineIndex + factor, 0, ...contextLines); + parallelLines.splice(parallelIndex + factor, 0, ...normalizedParallelLines); } } @@ -454,3 +460,48 @@ export const convertExpandLines = ({ }; export const idleCallback = cb => requestIdleCallback(cb); + +export const updateLineInFile = (selectedFile, lineCode, updateFn) => { + if (selectedFile.parallel_diff_lines) { + const targetLine = selectedFile.parallel_diff_lines.find( + line => + (line.left && line.left.line_code === lineCode) || + (line.right && line.right.line_code === lineCode), + ); + if (targetLine) { + const side = targetLine.left && targetLine.left.line_code === lineCode ? 'left' : 'right'; + + updateFn(targetLine[side]); + } + } + if (selectedFile.highlighted_diff_lines) { + const targetInlineLine = selectedFile.highlighted_diff_lines.find( + line => line.line_code === lineCode, + ); + + if (targetInlineLine) { + updateFn(targetInlineLine); + } + } +}; + +export const allDiscussionWrappersExpanded = diff => { + const discussionsExpandedArray = []; + if (diff.parallel_diff_lines) { + diff.parallel_diff_lines.forEach(line => { + if (line.left && line.left.discussions.length) { + discussionsExpandedArray.push(line.left.discussionsExpanded); + } + if (line.right && line.right.discussions.length) { + discussionsExpandedArray.push(line.right.discussionsExpanded); + } + }); + } else if (diff.highlighted_diff_lines) { + diff.parallel_diff_lines.forEach(line => { + if (line.discussions.length) { + discussionsExpandedArray.push(line.discussionsExpanded); + } + }); + } + return discussionsExpandedArray.every(el => el); +}; diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index 208bd19f6b0..21244c14977 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -1,5 +1,5 @@ <script> -import { s__, sprintf } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; import { formatTime } from '~/lib/utils/datetime_utility'; import Icon from '~/vue_shared/components/icon.vue'; import eventHub from '../event_hub'; @@ -28,7 +28,7 @@ export default { }, computed: { title() { - return 'Deploy to...'; + return __('Deploy to...'); }, }, methods: { @@ -80,7 +80,8 @@ export default { data-toggle="dropdown" > <span> - <icon name="play" /> <icon name="chevron-down" /> + <icon name="play" /> + <icon name="chevron-down" /> <gl-loading-icon v-if="isLoading" /> </span> </button> @@ -94,9 +95,10 @@ export default { class="js-manual-action-link no-btn btn d-flex align-items-center" @click="onClickAction(action)" > - <span class="flex-fill"> {{ action.name }} </span> + <span class="flex-fill">{{ action.name }}</span> <span v-if="action.scheduledAt" class="text-secondary"> - <icon name="clock" /> {{ remainingTime(action) }} + <icon name="clock" /> + {{ remainingTime(action) }} </span> </button> </li> diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index f0e80cba753..95e1e8af9b3 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -1,4 +1,6 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ +import { __, sprintf } from '~/locale'; import Timeago from 'timeago.js'; import _ from 'underscore'; import { GlTooltipDirective } from '@gitlab/ui'; @@ -14,7 +16,6 @@ import MonitoringButtonComponent from './environment_monitoring.vue'; import CommitComponent from '../../vue_shared/components/commit.vue'; import eventHub from '../event_hub'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { CLUSTER_TYPE } from '~/clusters/constants'; /** * Environment Item Component @@ -80,15 +81,6 @@ export default { }, /** - * Hide group cluster features which are not currently implemented. - * - * @returns {Boolean} - */ - disableGroupClusterFeatures() { - return this.model && this.model.cluster_type === CLUSTER_TYPE.GROUP; - }, - - /** * Returns whether the environment can be stopped. * * @returns {Boolean} @@ -172,7 +164,9 @@ export default { this.model.last_deployment.user && this.model.last_deployment.user.username ) { - return `${this.model.last_deployment.user.username}'s avatar'`; + return sprintf(__("%{username}'s avatar"), { + username: this.model.last_deployment.user.username, + }); } return ''; }, @@ -293,6 +287,9 @@ export default { * @returns {Boolean|Undefined} */ isLastDeployment() { + // name: 'last?' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives + // Vue i18n ESLint rules issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/63560 + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings return this.model && this.model.last_deployment && this.model.last_deployment['last?']; }, @@ -575,7 +572,6 @@ export default { <terminal-button-component v-if="model && model.terminal_path" :terminal-path="model.terminal_path" - :disabled="disableGroupClusterFeatures" /> <rollback-component diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue index ae4f07a71cd..886490847ea 100644 --- a/app/assets/javascripts/environments/components/environment_monitoring.vue +++ b/app/assets/javascripts/environments/components/environment_monitoring.vue @@ -1,4 +1,5 @@ <script> +import { __ } from '~/locale'; /** * Renders the Monitoring (Metrics) link in environments table. */ @@ -21,7 +22,7 @@ export default { }, computed: { title() { - return 'Monitoring'; + return __('Monitoring'); }, }, }; diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue index 13195d32cc4..37f94f9f5ab 100644 --- a/app/assets/javascripts/environments/components/environment_terminal_button.vue +++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue @@ -5,6 +5,7 @@ */ import { GlTooltipDirective } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; +import { __ } from '~/locale'; export default { components: { @@ -27,7 +28,7 @@ export default { }, computed: { title() { - return 'Terminal'; + return __('Terminal'); }, }, }; diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue index c78d86e9b97..2cc3412e075 100644 --- a/app/assets/javascripts/environments/components/stop_environment_modal.vue +++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue @@ -1,4 +1,5 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { GlTooltipDirective } from '@gitlab/ui'; import GlModal from '~/vue_shared/components/gl_modal.vue'; import { s__, sprintf } from '~/locale'; diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue index 060d8e25227..a734e8527dd 100644 --- a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue +++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue @@ -36,12 +36,14 @@ export default { <label class="label-bold" for="error-tracking-api-host">{{ __('Sentry API URL') }}</label> <div class="row"> <div class="col-8 col-md-9 gl-pr-0"> + <!-- eslint-disable @gitlab/vue-i18n/no-bare-attribute-strings --> <gl-form-input id="error-tracking-api-host" :value="apiHost" placeholder="https://mysentryserver.com" @input="$emit('update-api-host', $event)" /> + <!-- eslint-enable @gitlab/vue-i18n/no-bare-attribute-strings --> </div> </div> <p class="form-text text-muted"> @@ -49,9 +51,9 @@ export default { </p> </div> <div class="form-group" :class="{ 'gl-show-field-errors': connectError }"> - <label class="label-bold" for="error-tracking-token">{{ - s__('ErrorTracking|Auth Token') - }}</label> + <label class="label-bold" for="error-tracking-token"> + {{ s__('ErrorTracking|Auth Token') }} + </label> <div class="row"> <div class="col-8 col-md-9 gl-pr-0"> <gl-form-input @@ -65,9 +67,8 @@ export default { <gl-button class="js-error-tracking-connect prepend-left-5" @click="$emit('handle-connect')" + >{{ __('Connect') }}</gl-button > - {{ __('Connect') }} - </gl-button> <icon v-show="connectSuccessful" class="js-error-tracking-connect-success prepend-left-5 text-success align-middle" diff --git a/app/assets/javascripts/event_tracking/issue_sidebar.js b/app/assets/javascripts/event_tracking/issue_sidebar.js new file mode 100644 index 00000000000..6909f82c66f --- /dev/null +++ b/app/assets/javascripts/event_tracking/issue_sidebar.js @@ -0,0 +1,2 @@ +export const initSidebarTracking = () => {}; +export const trackEvent = () => {}; diff --git a/app/assets/javascripts/event_tracking/notes.js b/app/assets/javascripts/event_tracking/notes.js index 2d1ec238274..1f70290c397 100644 --- a/app/assets/javascripts/event_tracking/notes.js +++ b/app/assets/javascripts/event_tracking/notes.js @@ -1 +1,2 @@ +// Noop function which has a EE counter-part export default () => {}; diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js index 64b09c8b62c..77080691dcb 100644 --- a/app/assets/javascripts/filterable_list.js +++ b/app/assets/javascripts/filterable_list.js @@ -17,11 +17,13 @@ export default class FilterableList { } getFilterEndpoint() { - return `${this.filterForm.getAttribute('action')}?${$(this.filterForm).serialize()}`; + return this.getPagePath(); } getPagePath() { - return this.getFilterEndpoint(); + const action = this.filterForm.getAttribute('action'); + const params = $(this.filterForm).serialize(); + return `${action}${action.indexOf('?') > 0 ? '&' : '?'}${params}`; } initSearch() { diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue index 19bc3313373..4757c4b1e43 100644 --- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue +++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue @@ -59,7 +59,7 @@ export default { <template> <div> <div v-if="!isLocalStorageAvailable" class="dropdown-info-note"> - This feature requires local storage to be enabled + {{ __('This feature requires local storage to be enabled') }} </div> <ul v-else-if="hasItems"> <li v-for="(item, index) in processedItems" :key="`processed-items-${index}`"> @@ -90,10 +90,10 @@ export default { class="filtered-search-history-clear-button" @click="onRequestClearRecentSearches($event)" > - Clear recent searches + {{ __('Clear recent searches') }} </button> </li> </ul> - <div v-else class="dropdown-info-note">You don't have any recent searches</div> + <div v-else class="dropdown-info-note">{{ __("You don't have any recent searches") }}</div> </div> </template> diff --git a/app/assets/javascripts/graphql_shared/fragments/pageInfo.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/pageInfo.fragment.graphql new file mode 100644 index 00000000000..7403fd6d3c2 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/fragments/pageInfo.fragment.graphql @@ -0,0 +1,4 @@ +fragment PageInfo on PageInfo { + hasNextPage + endCursor +} diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js index 903c838e266..460174caf4d 100644 --- a/app/assets/javascripts/group.js +++ b/app/assets/javascripts/group.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import { slugifyWithHyphens } from './lib/utils/text_utility'; +import { slugify } from './lib/utils/text_utility'; export default class Group { constructor() { @@ -14,7 +14,7 @@ export default class Group { } update() { - const slug = slugifyWithHyphens(this.groupName.val()); + const slug = slugify(this.groupName.val()); this.groupPath.val(slug); } diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 9909f437fc8..830385941d8 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -129,7 +129,7 @@ export default { <item-stats-value :icon-name="visibilityIcon" :title="visibilityTooltip" - css-class="item-visibility d-inline-flex align-items-center prepend-top-8 append-right-4" + css-class="item-visibility d-inline-flex align-items-center prepend-top-8 append-right-4 text-secondary" /> <span v-if="group.permission" class="user-access-role prepend-top-8"> {{ group.permission }} diff --git a/app/assets/javascripts/ide/components/branches/item.vue b/app/assets/javascripts/ide/components/branches/item.vue index 4dff3f7e755..5c048749060 100644 --- a/app/assets/javascripts/ide/components/branches/item.vue +++ b/app/assets/javascripts/ide/components/branches/item.vue @@ -1,4 +1,5 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import Icon from '~/vue_shared/components/icon.vue'; import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; import router from '../../ide_router'; diff --git a/app/assets/javascripts/ide/components/branches/search_list.vue b/app/assets/javascripts/ide/components/branches/search_list.vue index 3cfdc1a367a..db8365a08e0 100644 --- a/app/assets/javascripts/ide/components/branches/search_list.vue +++ b/app/assets/javascripts/ide/components/branches/search_list.vue @@ -58,26 +58,24 @@ export default { <template> <div> - <div class="dropdown-input mt-3 pb-3 mb-0 border-bottom"> - <div class="position-relative"> - <input - ref="searchInput" - v-model="search" - :placeholder="__('Search branches')" - type="search" - class="form-control dropdown-input-field" - @input="searchBranches" - /> - <icon :size="18" name="search" class="input-icon" /> - </div> - </div> + <label class="dropdown-input pt-3 pb-3 mb-0 border-bottom block position-relative" @click.stop> + <input + ref="searchInput" + v-model="search" + :placeholder="__('Search branches')" + type="search" + class="form-control dropdown-input-field" + @input="searchBranches" + /> + <icon :size="18" name="search" class="ml-3 input-icon" /> + </label> <div class="dropdown-content ide-merge-requests-dropdown-content d-flex"> <gl-loading-icon v-if="isLoading" :size="2" class="mt-3 mb-3 align-self-center ml-auto mr-auto" /> - <ul v-else class="mb-3 w-100"> + <ul v-else class="mb-0 w-100"> <template v-if="hasBranches"> <li v-for="item in branches" :key="item.name"> <item :item="item" :project-id="currentProjectId" :is-active="isActiveBranch(item)" /> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue index 685d8a6b245..8b356ee6e97 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue @@ -41,10 +41,16 @@ export default { methods: { ...mapCommitActions(['updateCommitAction']), updateSelectedCommitAction() { - if (this.currentBranch && !this.currentBranch.can_push) { - this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH); - } else if (this.containsStagedChanges) { + if (!this.currentBranch) { + return; + } + + const { can_push: canPush = false, default: isDefault = false } = this.currentBranch; + + if (canPush && !isDefault) { this.updateCommitAction(consts.COMMIT_TO_CURRENT_BRANCH); + } else { + this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH); } }, }, diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue index 4be4b02ac1e..302adccd759 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -45,6 +45,8 @@ export default { }, computed: { iconName() { + // name: '-solid' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings const suffix = this.stagedList ? '-solid' : ''; return `${getCommitIconMap(this.file).icon}${suffix}`; @@ -107,7 +109,8 @@ export default { @click="openFileInEditor" > <span class="multi-file-commit-list-file-path d-flex align-items-center"> - <file-icon :file-name="file.name" class="append-right-8" />{{ file.name }} + <file-icon :file-name="file.name" class="append-right-8" /> + {{ file.name }} </span> <div class="ml-auto d-flex align-items-center"> <div class="d-flex align-items-center ide-commit-list-changed-icon"> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue index b2e7b15089c..daa44a42765 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue @@ -1,43 +1,36 @@ <script> -import { mapGetters, createNamespacedHelpers } from 'vuex'; +import { createNamespacedHelpers } from 'vuex'; const { mapState: mapCommitState, - mapGetters: mapCommitGetters, mapActions: mapCommitActions, + mapGetters: mapCommitGetters, } = createNamespacedHelpers('commit'); export default { computed: { ...mapCommitState(['shouldCreateMR']), - ...mapCommitGetters(['isCommittingToCurrentBranch', 'isCommittingToDefaultBranch']), - ...mapGetters(['hasMergeRequest', 'isOnDefaultBranch']), - currentBranchHasMr() { - return this.hasMergeRequest && this.isCommittingToCurrentBranch; - }, - showNewMrOption() { - return ( - this.isCommittingToDefaultBranch || !this.currentBranchHasMr || this.isCommittingToNewBranch - ); - }, - }, - mounted() { - this.setShouldCreateMR(); + ...mapCommitGetters(['shouldHideNewMrOption']), }, methods: { - ...mapCommitActions(['toggleShouldCreateMR', 'setShouldCreateMR']), + ...mapCommitActions(['toggleShouldCreateMR']), }, }; </script> <template> - <div v-if="showNewMrOption"> + <fieldset v-if="!shouldHideNewMrOption"> <hr class="my-2" /> - <label class="mb-0"> - <input :checked="shouldCreateMR" type="checkbox" @change="toggleShouldCreateMR" /> + <label class="mb-0 js-ide-commit-new-mr"> + <input + :checked="shouldCreateMR" + type="checkbox" + data-qa-selector="start_new_mr_checkbox" + @change="toggleShouldCreateMR" + /> <span class="prepend-left-10"> {{ __('Start a new merge request') }} </span> </label> - </div> + </fieldset> </template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue index b1d5de8682d..137f8bb18c7 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue @@ -10,7 +10,9 @@ export default { <template> <div class="multi-file-commit-panel-success-message" aria-live="assertive"> - <div class="svg-content svg-80"><img :src="committedStateSvgPath" alt="" /></div> + <div class="svg-content svg-80"> + <img :src="committedStateSvgPath" :alt="s__('IDE|Successful commit')" /> + </div> <div class="append-right-default prepend-left-default"> <div class="text-content text-center"> <h4>{{ __('All changes are committed') }}</h4> diff --git a/app/assets/javascripts/ide/components/external_link.vue b/app/assets/javascripts/ide/components/external_link.vue index 954f84cea17..d1857f0176a 100644 --- a/app/assets/javascripts/ide/components/external_link.vue +++ b/app/assets/javascripts/ide/components/external_link.vue @@ -27,7 +27,7 @@ export default { target="_blank" rel="noopener noreferrer" > - <span class="vertical-align-middle">Open in file view</span> + <span class="vertical-align-middle">{{ __('Open in file view') }}</span> <icon :size="16" name="external-link" css-classes="vertical-align-middle space-right" /> </a> </div> diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue index 80a6ab9598a..7254c50a568 100644 --- a/app/assets/javascripts/ide/components/file_row_extra.vue +++ b/app/assets/javascripts/ide/components/file_row_extra.vue @@ -87,7 +87,6 @@ export default { :file="file" :show-tooltip="true" :show-staged-icon="true" - :force-modified-icon="true" /> <new-dropdown :type="file.type" diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index 206b8341aad..326589fa50f 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -1,4 +1,5 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { mapActions, mapState, mapGetters } from 'vuex'; import IdeStatusList from 'ee_else_ce/ide/components/ide_status_list.vue'; import icon from '~/vue_shared/components/icon.vue'; diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue index 2d55ffb3c65..5daf2d1422c 100644 --- a/app/assets/javascripts/ide/components/merge_requests/list.vue +++ b/app/assets/javascripts/ide/components/merge_requests/list.vue @@ -76,19 +76,17 @@ export default { <template> <div> - <div class="dropdown-input mt-3 pb-3 mb-0 border-bottom"> - <div class="position-relative"> - <tokened-input - v-model="search" - :tokens="searchTokens" - :placeholder="__('Search merge requests')" - @focus="onSearchFocus" - @input="searchMergeRequests" - @removeToken="setSearchType(null)" - /> - <icon :size="18" name="search" class="input-icon" /> - </div> - </div> + <label class="dropdown-input pt-3 pb-3 mb-0 border-bottom block" @click.stop> + <tokened-input + v-model="search" + :tokens="searchTokens" + :placeholder="__('Search merge requests')" + @focus="onSearchFocus" + @input="searchMergeRequests" + @removeToken="setSearchType(null)" + /> + <icon :size="18" name="search" class="ml-3 input-icon" /> + </label> <div class="dropdown-content ide-merge-requests-dropdown-content d-flex"> <gl-loading-icon v-if="isLoading" @@ -96,7 +94,7 @@ export default { class="mt-3 mb-3 align-self-center ml-auto mr-auto" /> <template v-else> - <ul class="mb-3 w-100"> + <ul class="mb-0 w-100"> <template v-if="showSearchTypes"> <li v-for="searchType in $options.searchTypes" :key="searchType.type"> <button @@ -107,7 +105,7 @@ export default { <span class="d-flex append-right-default ide-search-list-current-icon"> <icon :size="18" name="search" /> </span> - <span> {{ searchType.label }} </span> + <span>{{ searchType.label }}</span> </button> </li> </template> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index b0c4969c5e4..802b7f1fa6f 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -4,10 +4,16 @@ import { viewerInformationForPath } from '~/vue_shared/components/content_viewer import flash from '~/flash'; import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; -import { activityBarViews, viewerTypes } from '../constants'; +import { + activityBarViews, + viewerTypes, + FILE_VIEW_MODE_EDITOR, + FILE_VIEW_MODE_PREVIEW, +} from '../constants'; import Editor from '../lib/editor'; import ExternalLink from './external_link.vue'; import FileTemplatesBar from './file_templates/bar.vue'; +import { __ } from '~/locale'; export default { components: { @@ -40,27 +46,36 @@ export default { }, showContentViewer() { return ( - (this.shouldHideEditor || this.file.viewMode === 'preview') && + (this.shouldHideEditor || this.isPreviewViewMode) && (this.viewer !== viewerTypes.mr || !this.file.mrChange) ); }, showDiffViewer() { return this.shouldHideEditor && this.file.mrChange && this.viewer === viewerTypes.mr; }, + isEditorViewMode() { + return this.file.viewMode === FILE_VIEW_MODE_EDITOR; + }, + isPreviewViewMode() { + return this.file.viewMode === FILE_VIEW_MODE_PREVIEW; + }, editTabCSS() { return { - active: this.file.viewMode === 'editor', + active: this.isEditorViewMode, }; }, previewTabCSS() { return { - active: this.file.viewMode === 'preview', + active: this.isPreviewViewMode, }; }, fileType() { const info = viewerInformationForPath(this.file.path); return (info && info.id) || ''; }, + showEditor() { + return !this.shouldHideEditor && this.isEditorViewMode; + }, }, watch: { file(newVal, oldVal) { @@ -75,7 +90,7 @@ export default { if (this.currentActivityView !== activityBarViews.edit) { this.setFileViewMode({ file: this.file, - viewMode: 'editor', + viewMode: FILE_VIEW_MODE_EDITOR, }); } } @@ -84,12 +99,12 @@ export default { if (this.currentActivityView !== activityBarViews.edit) { this.setFileViewMode({ file: this.file, - viewMode: 'editor', + viewMode: FILE_VIEW_MODE_EDITOR, }); } }, rightPanelCollapsed() { - this.editor.updateDimensions(); + this.refreshEditorDimensions(); }, viewer() { if (!this.file.pending) { @@ -98,11 +113,17 @@ export default { }, panelResizing() { if (!this.panelResizing) { - this.editor.updateDimensions(); + this.refreshEditorDimensions(); } }, rightPaneIsOpen() { - this.editor.updateDimensions(); + this.refreshEditorDimensions(); + }, + showEditor(val) { + if (val) { + // We need to wait for the editor to actually be rendered. + this.$nextTick(() => this.refreshEditorDimensions()); + } }, }, beforeDestroy() { @@ -128,7 +149,9 @@ export default { 'triggerFilesChange', ]), initEditor() { - if (this.shouldHideEditor) return; + if (this.shouldHideEditor && (this.file.content || this.file.raw)) { + return; + } this.editor.clearEditor(); @@ -145,7 +168,14 @@ export default { this.createEditorInstance(); }) .catch(err => { - flash('Error setting up editor. Please try again.', 'alert', document, null, false, true); + flash( + __('Error setting up editor. Please try again.'), + 'alert', + document, + null, + false, + true, + ); throw err; }); }, @@ -212,8 +242,15 @@ export default { eol: this.model.eol, }); }, + refreshEditorDimensions() { + if (this.showEditor) { + this.editor.updateDimensions(); + } + }, }, viewerTypes, + FILE_VIEW_MODE_EDITOR, + FILE_VIEW_MODE_PREVIEW, }; </script> @@ -225,31 +262,26 @@ export default { <a href="javascript:void(0);" role="button" - @click.prevent="setFileViewMode({ file, viewMode: 'editor' })" + @click.prevent="setFileViewMode({ file, viewMode: $options.FILE_VIEW_MODE_EDITOR })" > - <template v-if="viewer === $options.viewerTypes.edit"> - {{ __('Edit') }} - </template> - <template v-else> - {{ __('Review') }} - </template> + <template v-if="viewer === $options.viewerTypes.edit">{{ __('Edit') }}</template> + <template v-else>{{ __('Review') }}</template> </a> </li> <li v-if="file.previewMode" :class="previewTabCSS"> <a href="javascript:void(0);" role="button" - @click.prevent="setFileViewMode({ file, viewMode: 'preview' })" + @click.prevent="setFileViewMode({ file, viewMode: $options.FILE_VIEW_MODE_PREVIEW })" + >{{ file.previewMode.previewTitle }}</a > - {{ file.previewMode.previewTitle }} - </a> </li> </ul> <external-link :file="file" /> </div> <file-templates-bar v-if="showFileTemplatesBar(file.name)" /> <div - v-show="!shouldHideEditor && file.viewMode === 'editor'" + v-show="showEditor" ref="editor" :class="{ 'is-readonly': isCommitModeActive, diff --git a/app/assets/javascripts/ide/components/repo_file_status_icon.vue b/app/assets/javascripts/ide/components/repo_file_status_icon.vue index a964d90b090..84a962bfc7d 100644 --- a/app/assets/javascripts/ide/components/repo_file_status_icon.vue +++ b/app/assets/javascripts/ide/components/repo_file_status_icon.vue @@ -1,4 +1,5 @@ <script> +import { __, sprintf } from '~/locale'; import icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; import '~/lib/utils/datetime_utility'; @@ -18,7 +19,9 @@ export default { }, computed: { lockTooltip() { - return `Locked by ${this.file.file_lock.user.name}`; + return sprintf(__(`Locked by %{fileLockUserName}`), { + fileLockUserName: this.file.file_lock.user.name, + }); }, }, }; diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue index f6aa2295844..7615cfc966e 100644 --- a/app/assets/javascripts/ide/components/repo_tab.vue +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -1,4 +1,5 @@ <script> +import { __, sprintf } from '~/locale'; import { mapActions } from 'vuex'; import FileIcon from '~/vue_shared/components/file_icon.vue'; @@ -27,9 +28,9 @@ export default { computed: { closeLabel() { if (this.fileHasChanged) { - return `${this.tab.name} changed`; + return sprintf(__(`%{tabname} changed`), { tabname: this.tab.name }); } - return `Close ${this.tab.name}`; + return sprintf(__(`Close %{tabname}`, { tabname: this.tab.name })); }, showChangedIcon() { if (this.tab.pending) return true; diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index e30670e119f..673ac1bfa9a 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -4,6 +4,10 @@ export const MAX_WINDOW_HEIGHT_COMPACT = 750; export const MAX_TITLE_LENGTH = 50; export const MAX_BODY_LENGTH = 72; +// File view modes +export const FILE_VIEW_MODE_EDITOR = 'editor'; +export const FILE_VIEW_MODE_PREVIEW = 'preview'; + export const activityBarViews = { edit: 'ide-tree', commit: 'commit-section', diff --git a/app/assets/javascripts/ide/lib/files.js b/app/assets/javascripts/ide/lib/files.js index b8abaa41f23..51278640b5b 100644 --- a/app/assets/javascripts/ide/lib/files.js +++ b/app/assets/javascripts/ide/lib/files.js @@ -77,6 +77,7 @@ export const decorateFiles = ({ const fileFolder = parent && insertParent(parent); if (name) { + const previewMode = viewerInformationForPath(name); parentPath = fileFolder && fileFolder.path; file = decorateData({ @@ -92,9 +93,9 @@ export const decorateFiles = ({ changed: tempFile, content, base64, - binary, + binary: (previewMode && previewMode.binary) || binary, rawPath, - previewMode: viewerInformationForPath(name), + previewMode, parentPath, }); diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index 840761f68db..ba33b6826d6 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -56,13 +56,7 @@ export default { return Api.branchSingle(projectId, currentBranchId); }, commit(projectId, payload) { - // Currently the `commit` endpoint does not support `start_sha` so we - // have to make the request in the FE. This is not ideal and will be - // resolved soon. https://gitlab.com/gitlab-org/gitlab-ce/issues/59023 - const { branch, start_sha: ref } = payload; - const branchPromise = ref ? Api.createBranch(projectId, { ref, branch }) : Promise.resolve(); - - return branchPromise.then(() => Api.commitMultiple(projectId, payload)); + return Api.commitMultiple(projectId, payload); }, getFiles(projectUrl, branchId) { const url = `${projectUrl}/files/${branchId}`; diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index 507dc363529..8c0119a1fed 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -62,7 +62,7 @@ export const createTempEntry = ( new Promise(resolve => { const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name; - if (state.entries[name]) { + if (state.entries[name] && !state.entries[name].deleted) { flash( `The name "${name.split('/').pop()}" is already taken in this directory.`, 'alert', @@ -208,6 +208,7 @@ export const deleteEntry = ({ commit, dispatch, state }, path) => { } commit(types.DELETE_ENTRY, path); + dispatch('stageChange', path); dispatch('triggerFilesChange'); }; diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index 406903129db..85fd45358be 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -104,5 +104,8 @@ export const packageJson = state => state.entries[packageJsonPath]; export const isOnDefaultBranch = (_state, getters) => getters.currentProject && getters.currentProject.default_branch === getters.branchName; +export const canPushToBranch = (_state, getters) => + getters.currentBranch && getters.currentBranch.can_push; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index 01ca6a6b12f..23caf2d48ed 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -18,34 +18,15 @@ export const discardDraft = ({ commit }) => { commit(types.UPDATE_COMMIT_MESSAGE, ''); }; -export const updateCommitAction = ({ commit, dispatch }, commitAction) => { +export const updateCommitAction = ({ commit, getters }, commitAction) => { commit(types.UPDATE_COMMIT_ACTION, { commitAction, }); - dispatch('setShouldCreateMR'); + commit(types.TOGGLE_SHOULD_CREATE_MR, !getters.shouldHideNewMrOption); }; export const toggleShouldCreateMR = ({ commit }) => { commit(types.TOGGLE_SHOULD_CREATE_MR); - commit(types.INTERACT_WITH_NEW_MR); -}; - -export const setShouldCreateMR = ({ - commit, - getters, - rootGetters, - state: { interactedWithNewMR }, -}) => { - const committingToExistingMR = - getters.isCommittingToCurrentBranch && - rootGetters.hasMergeRequest && - !rootGetters.isOnDefaultBranch; - - if ((getters.isCommittingToDefaultBranch && !interactedWithNewMR) || committingToExistingMR) { - commit(types.TOGGLE_SHOULD_CREATE_MR, false); - } else if (!interactedWithNewMR) { - commit(types.TOGGLE_SHOULD_CREATE_MR, true); - } }; export const updateBranchName = ({ commit }, branchName) => { @@ -186,6 +167,8 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo commit(rootTypes.CLEAR_STAGED_CHANGES, null, { root: true }); + commit(rootTypes.CLEAR_REPLACED_FILES, null, { root: true }); + setTimeout(() => { commit(rootTypes.SET_LAST_COMMIT_MSG, '', { root: true }); }, 5000); diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js index 64779e9e4df..de289e27199 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/getters.js +++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js @@ -20,7 +20,7 @@ export const placeholderBranchName = (state, _, rootState) => )}`; export const branchName = (state, getters, rootState) => { - if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH) { + if (getters.isCreatingNewBranch) { if (state.newBranchName === '') { return getters.placeholderBranchName; } @@ -48,11 +48,11 @@ export const preBuiltCommitMessage = (state, _, rootState) => { export const isCreatingNewBranch = state => state.commitAction === consts.COMMIT_TO_NEW_BRANCH; -export const isCommittingToCurrentBranch = state => - state.commitAction === consts.COMMIT_TO_CURRENT_BRANCH; - -export const isCommittingToDefaultBranch = (_state, getters, _rootState, rootGetters) => - getters.isCommittingToCurrentBranch && rootGetters.isOnDefaultBranch; +export const shouldHideNewMrOption = (_state, getters, _rootState, rootGetters) => + !getters.isCreatingNewBranch && + (rootGetters.hasMergeRequest || + (!rootGetters.hasMergeRequest && rootGetters.isOnDefaultBranch)) && + rootGetters.canPushToBranch; // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js index b81918156b0..7ad8f3570b7 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js +++ b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js @@ -3,4 +3,3 @@ export const UPDATE_COMMIT_ACTION = 'UPDATE_COMMIT_ACTION'; export const UPDATE_NEW_BRANCH_NAME = 'UPDATE_NEW_BRANCH_NAME'; export const UPDATE_LOADING = 'UPDATE_LOADING'; export const TOGGLE_SHOULD_CREATE_MR = 'TOGGLE_SHOULD_CREATE_MR'; -export const INTERACT_WITH_NEW_MR = 'INTERACT_WITH_NEW_MR'; diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutations.js b/app/assets/javascripts/ide/stores/modules/commit/mutations.js index 14957d283bb..73b618e250f 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/mutations.js +++ b/app/assets/javascripts/ide/stores/modules/commit/mutations.js @@ -24,7 +24,4 @@ export default { shouldCreateMR: shouldCreateMR === undefined ? !state.shouldCreateMR : shouldCreateMR, }); }, - [types.INTERACT_WITH_NEW_MR](state) { - Object.assign(state, { interactedWithNewMR: true }); - }, }; diff --git a/app/assets/javascripts/ide/stores/modules/commit/state.js b/app/assets/javascripts/ide/stores/modules/commit/state.js index 53647a7e3e3..259577e48e0 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/state.js +++ b/app/assets/javascripts/ide/stores/modules/commit/state.js @@ -3,6 +3,5 @@ export default () => ({ commitAction: '1', newBranchName: '', submitCommitLoading: false, - shouldCreateMR: false, - interactedWithNewMR: false, + shouldCreateMR: true, }); diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index 86ab76136df..f021729c451 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -60,6 +60,8 @@ export const CLEAR_STAGED_CHANGES = 'CLEAR_STAGED_CHANGES'; export const STAGE_CHANGE = 'STAGE_CHANGE'; export const UNSTAGE_CHANGE = 'UNSTAGE_CHANGE'; +export const CLEAR_REPLACED_FILES = 'CLEAR_REPLACED_FILES'; + export const UPDATE_FILE_AFTER_COMMIT = 'UPDATE_FILE_AFTER_COMMIT'; export const ADD_PENDING_TAB = 'ADD_PENDING_TAB'; export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index ec4c2fdcde2..ea125214ebb 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -56,6 +56,11 @@ export default { stagedFiles: [], }); }, + [types.CLEAR_REPLACED_FILES](state) { + Object.assign(state, { + replacedFiles: [], + }); + }, [types.SET_ENTRIES](state, entries) { Object.assign(state, { entries, @@ -70,6 +75,13 @@ export default { Object.assign(state.entries, { [key]: entry, }); + } else if (foundEntry.deleted) { + Object.assign(state.entries, { + [key]: { + ...entry, + replaces: true, + }, + }); } else { const tree = entry.tree.filter( f => foundEntry.tree.find(e => e.path === f.path) === undefined, @@ -144,6 +156,7 @@ export default { raw: file.content, changed: Boolean(changedFile), staged: false, + replaces: false, prevPath: '', moved: false, lastCommitSha: lastCommit.commit.id, diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index 6ca246c1d63..1442ea7dbfa 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -1,6 +1,7 @@ import * as types from '../mutation_types'; import { sortTree } from '../utils'; import { diffModes } from '../../constants'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; export default { [types.SET_FILE_ACTIVE](state, { path, active }) { @@ -35,19 +36,22 @@ export default { } }, [types.SET_FILE_DATA](state, { data, file }) { - Object.assign(state.entries[file.path], { - id: data.id, - blamePath: data.blame_path, - commitsPath: data.commits_path, - permalink: data.permalink, - rawPath: data.raw_path, - binary: data.binary, - renderError: data.render_error, - raw: (state.entries[file.path] && state.entries[file.path].raw) || null, - baseRaw: null, - html: data.html, - size: data.size, - lastCommitSha: data.last_commit_sha, + const stateEntry = state.entries[file.path]; + const stagedFile = state.stagedFiles.find(f => f.path === file.path); + const openFile = state.openFiles.find(f => f.path === file.path); + const changedFile = state.changedFiles.find(f => f.path === file.path); + + [stateEntry, stagedFile, openFile, changedFile].forEach(f => { + if (f) { + Object.assign( + f, + convertObjectPropsToCamelCase(data, { dropKeys: ['path', 'name', 'raw', 'baseRaw'] }), + { + raw: (stateEntry && stateEntry.raw) || null, + baseRaw: null, + }, + ); + } }); }, [types.SET_FILE_RAW_DATA](state, { file, raw }) { @@ -170,12 +174,16 @@ export default { entries: Object.assign(state.entries, { [path]: Object.assign(state.entries[path], { staged: true, - changed: false, }), }), }); if (stagedFile) { + Object.assign(state, { + replacedFiles: state.replacedFiles.concat({ + ...stagedFile, + }), + }); Object.assign(stagedFile, { ...state.entries[path], }); diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index d400b9831a9..c4da482bf0a 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -6,6 +6,7 @@ export default () => ({ currentMergeRequestId: '', changedFiles: [], stagedFiles: [], + replacedFiles: [], endpoints: {}, lastCommitMsg: '', lastCommitPath: '', diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index fb132c1afc1..52200ce7847 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -1,4 +1,4 @@ -import { commitActionTypes } from '../constants'; +import { commitActionTypes, FILE_VIEW_MODE_EDITOR } from '../constants'; export const dataStructure = () => ({ id: '', @@ -18,6 +18,7 @@ export const dataStructure = () => ({ active: false, changed: false, staged: false, + replaces: false, lastCommitPath: '', lastCommitSha: '', lastCommit: { @@ -42,7 +43,7 @@ export const dataStructure = () => ({ editorColumn: 1, fileLanguage: '', eol: '', - viewMode: 'editor', + viewMode: FILE_VIEW_MODE_EDITOR, previewMode: null, size: 0, parentPath: null, @@ -119,7 +120,7 @@ export const commitActionForFile = file => { return commitActionTypes.move; } else if (file.deleted) { return commitActionTypes.delete; - } else if (file.tempFile) { + } else if (file.tempFile && !file.replaces) { return commitActionTypes.create; } @@ -128,7 +129,7 @@ export const commitActionForFile = file => { export const getCommitFiles = stagedFiles => stagedFiles.reduce((acc, file) => { - if (file.moved) return acc; + if (file.moved || file.type === 'tree') return acc; return acc.concat({ ...file, @@ -151,13 +152,14 @@ export const createCommitPayload = ({ previous_path: f.prevPath === '' ? undefined : f.prevPath, content: f.prevPath ? null : f.content || undefined, encoding: f.base64 ? 'base64' : 'text', - last_commit_id: newBranch || f.deleted || f.prevPath ? undefined : f.lastCommitSha, + last_commit_id: + newBranch || f.deleted || f.prevPath || f.replaces ? undefined : f.lastCommitSha, })), - start_sha: newBranch ? rootGetters.lastCommit.short_id : undefined, + start_sha: newBranch ? rootGetters.lastCommit.id : undefined, }); export const createNewMergeRequestUrl = (projectUrl, source, target) => - `${projectUrl}/merge_requests/new?merge_request[source_branch]=${source}&merge_request[target_branch]=${target}`; + `${projectUrl}/merge_requests/new?merge_request[source_branch]=${source}&merge_request[target_branch]=${target}&nav_source=webide`; const sortTreesByTypeAndName = (a, b) => { if (a.type === 'tree' && b.type === 'blob') { diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js index bc9d7fcf30d..c855f3973b0 100644 --- a/app/assets/javascripts/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable_bulk_update_actions.js @@ -1,4 +1,4 @@ -/* eslint-disable consistent-return, func-names, array-callback-return, prefer-arrow-callback, no-unused-vars */ +/* eslint-disable consistent-return, func-names, array-callback-return, prefer-arrow-callback */ import $ from 'jquery'; import _ from 'underscore'; @@ -7,7 +7,7 @@ import Flash from './flash'; import { __ } from './locale'; export default { - init({ container, form, issues, prefixId } = {}) { + init({ form, issues, prefixId } = {}) { this.prefixId = prefixId || 'issue_'; this.form = form || this.getElement('.bulk-update'); this.$labelDropdown = this.form.find('.js-label-select'); diff --git a/app/assets/javascripts/issuable_index.js b/app/assets/javascripts/issuable_index.js index 16f88cddce3..f3f8b6ec715 100644 --- a/app/assets/javascripts/issuable_index.js +++ b/app/assets/javascripts/issuable_index.js @@ -2,26 +2,13 @@ import $ from 'jquery'; import axios from './lib/utils/axios_utils'; import flash from './flash'; import { s__, __ } from './locale'; -import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar'; -import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; +import issuableInitBulkUpdateSidebar from './issuable_init_bulk_update_sidebar'; export default class IssuableIndex { constructor(pagePrefix) { - this.initBulkUpdate(pagePrefix); + issuableInitBulkUpdateSidebar.init(pagePrefix); IssuableIndex.resetIncomingEmailToken(); } - initBulkUpdate(pagePrefix) { - const userCanBulkUpdate = $('.issues-bulk-update').length > 0; - const alreadyInitialized = Boolean(this.bulkUpdateSidebar); - - if (userCanBulkUpdate && !alreadyInitialized) { - IssuableBulkUpdateActions.init({ - prefixId: pagePrefix, - }); - - this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar(); - } - } static resetIncomingEmailToken() { const $resetToken = $('.incoming-email-token-reset'); diff --git a/app/assets/javascripts/issuable_init_bulk_update_sidebar.js b/app/assets/javascripts/issuable_init_bulk_update_sidebar.js new file mode 100644 index 00000000000..da8969c80f3 --- /dev/null +++ b/app/assets/javascripts/issuable_init_bulk_update_sidebar.js @@ -0,0 +1,19 @@ +import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar'; +import issuableBulkUpdateActions from './issuable_bulk_update_actions'; + +export default { + bulkUpdateSidebar: null, + + init(prefixId) { + const bulkUpdateEl = document.querySelector('.issues-bulk-update'); + const alreadyInitialized = Boolean(this.bulkUpdateSidebar); + + if (bulkUpdateEl && !alreadyInitialized) { + issuableBulkUpdateActions.init({ prefixId }); + + this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar(); + } + + return this.bulkUpdateSidebar; + }, +}; diff --git a/app/assets/javascripts/issuable_suggestions/components/item.vue b/app/assets/javascripts/issuable_suggestions/components/item.vue index 9a16b486bf5..7629e04684c 100644 --- a/app/assets/javascripts/issuable_suggestions/components/item.vue +++ b/app/assets/javascripts/issuable_suggestions/components/item.vue @@ -1,4 +1,5 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import _ from 'underscore'; import { GlLink, GlTooltip, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index de2a9664cde..9ca38d6bbfa 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -55,6 +55,11 @@ export default { required: false, default: true, }, + zoomMeetingUrl: { + type: String, + required: false, + default: null, + }, issuableRef: { type: String, required: true, @@ -342,7 +347,7 @@ export default { :title-text="state.titleText" :show-inline-edit-button="showInlineEditButton" /> - <pinned-links :description-html="state.descriptionHtml" /> + <pinned-links :zoom-meeting-url="zoomMeetingUrl" /> <description-component v-if="state.descriptionHtml" :can-update="canUpdate" diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue index b2f9296c68b..eb51a074f84 100644 --- a/app/assets/javascripts/issue_show/components/edit_actions.vue +++ b/app/assets/javascripts/issue_show/components/edit_actions.vue @@ -1,4 +1,5 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { __, sprintf } from '~/locale'; import updateMixin from '../mixins/update'; import eventHub from '../event_hub'; diff --git a/app/assets/javascripts/issue_show/components/edited.vue b/app/assets/javascripts/issue_show/components/edited.vue index 14ad8d3b7c9..2c92324d292 100644 --- a/app/assets/javascripts/issue_show/components/edited.vue +++ b/app/assets/javascripts/issue_show/components/edited.vue @@ -1,4 +1,5 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; export default { diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue index 6f955928d8e..bc3c81d479e 100644 --- a/app/assets/javascripts/issue_show/components/fields/description_template.vue +++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue @@ -1,4 +1,5 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import $ from 'jquery'; import IssuableTemplateSelectors from '../../../templates/issuable_template_selectors'; diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue index 528ccb77efc..d48bf1fe7a9 100644 --- a/app/assets/javascripts/issue_show/components/form.vue +++ b/app/assets/javascripts/issue_show/components/form.vue @@ -70,6 +70,9 @@ export default { hasIssuableTemplates() { return this.issuableTemplates.length; }, + showLockedWarning() { + return this.formState.lockedWarningVisible && !this.formState.updateLoading; + }, }, created() { eventHub.$on('delete.issuable', this.resetAutosave); @@ -117,7 +120,7 @@ export default { <template> <form> - <locked-warning v-if="formState.lockedWarningVisible" /> + <locked-warning v-if="showLockedWarning" /> <div class="row"> <div v-if="hasIssuableTemplates" class="col-sm-4 col-lg-3"> <description-template diff --git a/app/assets/javascripts/issue_show/components/locked_warning.vue b/app/assets/javascripts/issue_show/components/locked_warning.vue index 2f3e611e089..19c7a11d87b 100644 --- a/app/assets/javascripts/issue_show/components/locked_warning.vue +++ b/app/assets/javascripts/issue_show/components/locked_warning.vue @@ -1,18 +1,27 @@ <script> +import { __, sprintf } from '~/locale'; + export default { computed: { currentPath() { return window.location.pathname; }, + alertMessage() { + return sprintf( + __( + 'Someone edited the issue at the same time you did. Please check out %{linkStart}the issue%{linkEnd} and make sure your changes will not unintentionally remove theirs.', + ), + { + linkStart: `<a href="${this.currentPath}" target="_blank" rel="nofollow">`, + linkEnd: `</a>`, + }, + false, + ); + }, }, }; </script> <template> - <div class="alert alert-danger"> - {{ sprintf(__("Someone edited the issue at the same time you did. Please check out - %{linkStart}%the issue%{linkEnd} and make sure your changes will not unintentionally remove - theirs."), { linkStart: `<a href="${currentPath}" target="_blank" rel="nofollow">` linkEnd: '</a - >', }) }} - </div> + <div class="alert alert-danger" v-html="alertMessage"></div> </template> diff --git a/app/assets/javascripts/issue_show/components/pinned_links.vue b/app/assets/javascripts/issue_show/components/pinned_links.vue index 7a54b26bc2b..965e8a3d751 100644 --- a/app/assets/javascripts/issue_show/components/pinned_links.vue +++ b/app/assets/javascripts/issue_show/components/pinned_links.vue @@ -8,40 +8,19 @@ export default { GlLink, }, props: { - descriptionHtml: { + zoomMeetingUrl: { type: String, - required: true, - }, - }, - computed: { - linksInDescription() { - const el = document.createElement('div'); - el.innerHTML = this.descriptionHtml; - return [...el.querySelectorAll('a')].map(a => a.href); - }, - // Detect links matching the following formats: - // Zoom Start links: https://zoom.us/s/<meeting-id> - // Zoom Join links: https://zoom.us/j/<meeting-id> - // Personal Zoom links: https://zoom.us/my/<meeting-id> - // Vanity Zoom links: https://gitlab.zoom.us/j/<meeting-id> (also /s and /my) - zoomHref() { - const zoomRegex = /^https:\/\/([\w\d-]+\.)?zoom\.us\/(s|j|my)\/.+/; - return this.linksInDescription.reduce((acc, currentLink) => { - let lastLink = acc; - if (zoomRegex.test(currentLink)) { - lastLink = currentLink; - } - return lastLink; - }, ''); + required: false, + default: null, }, }, }; </script> <template> - <div v-if="zoomHref" class="border-bottom mb-3 mt-n2"> + <div v-if="zoomMeetingUrl" class="border-bottom mb-3 mt-n2"> <gl-link - :href="zoomHref" + :href="zoomMeetingUrl" target="_blank" class="btn btn-inverted btn-secondary btn-sm text-dark mb-3" > diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index 529b6386221..5a9dd91817e 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import { initSidebarTracking } from 'ee_else_ce/event_tracking/issue_sidebar'; import issuableApp from './components/app.vue'; import { parseIssuableData } from './utils/parse_data'; import '../vue_shared/vue_resource_interceptor'; @@ -9,6 +10,9 @@ export default function initIssueableApp() { components: { issuableApp, }, + mounted() { + initSidebarTracking(); + }, render(createElement) { return createElement('issuable-app', { props: parseIssuableData(), diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue index b651a6e4bfb..9fac880c5f8 100644 --- a/app/assets/javascripts/jobs/components/commit_block.vue +++ b/app/assets/javascripts/jobs/components/commit_block.vue @@ -1,4 +1,5 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { GlLink } from '@gitlab/ui'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; diff --git a/app/assets/javascripts/jobs/components/empty_state.vue b/app/assets/javascripts/jobs/components/empty_state.vue index 04f910b6b80..e2bc413e3ce 100644 --- a/app/assets/javascripts/jobs/components/empty_state.vue +++ b/app/assets/javascripts/jobs/components/empty_state.vue @@ -1,9 +1,11 @@ <script> import { GlLink } from '@gitlab/ui'; +import ManualVariablesForm from './manual_variables_form.vue'; export default { components: { GlLink, + ManualVariablesForm, }, props: { illustrationPath: { @@ -23,6 +25,21 @@ export default { required: false, default: null, }, + playable: { + type: Boolean, + required: true, + default: false, + }, + scheduled: { + type: Boolean, + required: false, + default: false, + }, + variablesSettingsUrl: { + type: String, + required: false, + default: null, + }, action: { type: Object, required: false, @@ -37,28 +54,40 @@ export default { }, }, }, + computed: { + shouldRenderManualVariables() { + return this.playable && !this.scheduled; + }, + }, }; </script> <template> <div class="row empty-state"> <div class="col-12"> - <div :class="illustrationSizeClass" class="svg-content"><img :src="illustrationPath" /></div> + <div :class="illustrationSizeClass" class="svg-content"> + <img :src="illustrationPath" /> + </div> </div> <div class="col-12"> <div class="text-content"> <h4 class="js-job-empty-state-title text-center">{{ title }}</h4> - <p v-if="content" class="js-job-empty-state-content text-center">{{ content }}</p> - - <div v-if="action" class="text-center"> + <p v-if="content" class="js-job-empty-state-content">{{ content }}</p> + </div> + <manual-variables-form + v-if="shouldRenderManualVariables" + :action="action" + :variables-settings-url="variablesSettingsUrl" + /> + <div class="text-content"> + <div v-if="action && !shouldRenderManualVariables" class="text-center"> <gl-link :href="action.path" :data-method="action.method" class="js-job-empty-state-action btn btn-primary" + >{{ action.button_title }}</gl-link > - {{ action.button_title }} - </gl-link> </div> </div> </div> diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index 79fb67d38cd..8da87f424c4 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -45,6 +45,11 @@ export default { required: false, default: null, }, + variablesSettingsUrl: { + type: String, + required: false, + default: null, + }, runnerHelpUrl: { type: String, required: false, @@ -68,6 +73,10 @@ export default { type: String, required: true, }, + projectPath: { + type: String, + required: true, + }, logState: { type: String, required: true, @@ -253,6 +262,7 @@ export default { :quota-used="job.runners.quota.used" :quota-limit="job.runners.quota.limit" :runners-path="runnerHelpUrl" + :project-path="projectPath" /> <environments-block @@ -313,6 +323,9 @@ export default { :title="emptyStateTitle" :content="emptyStateIllustration.content" :action="emptyStateAction" + :playable="job.playable" + :scheduled="job.scheduled" + :variables-settings-url="variablesSettingsUrl" /> <!-- EO empty state --> diff --git a/app/assets/javascripts/jobs/components/job_log.vue b/app/assets/javascripts/jobs/components/job_log.vue index d611b370ab9..a3fbe9338ee 100644 --- a/app/assets/javascripts/jobs/components/job_log.vue +++ b/app/assets/javascripts/jobs/components/job_log.vue @@ -48,9 +48,14 @@ export default { } }, removeEventListener() { - this.$el - .querySelectorAll('.js-section-start') - .forEach(el => el.removeEventListener('click', this.handleSectionClick)); + this.$el.querySelectorAll('.js-section-start').forEach(el => { + const titleSection = el.nextSibling; + titleSection.removeEventListener( + 'click', + this.handleHeaderClick.bind(this, el, el.dataset.section), + ); + el.removeEventListener('click', this.handleSectionClick); + }); }, /** * The collapsible rows are sent in HTML from the backend @@ -58,9 +63,28 @@ export default { * */ handleCollapsibleRows() { - this.$el - .querySelectorAll('.js-section-start') - .forEach(el => el.addEventListener('click', this.handleSectionClick)); + this.$el.querySelectorAll('.js-section-start').forEach(el => { + const titleSection = el.nextSibling; + titleSection.addEventListener( + 'click', + this.handleHeaderClick.bind(this, el, el.dataset.section), + ); + el.addEventListener('click', this.handleSectionClick); + }); + }, + + handleHeaderClick(arrowElement, section) { + this.updateToggleSection(arrowElement, section); + }, + + updateToggleSection(arrow, section) { + // toggle the arrow class + arrow.classList.toggle('fa-caret-right'); + arrow.classList.toggle('fa-caret-down'); + + // hide the sections + const sibilings = this.$el.querySelectorAll(`.js-s-${section}:not(.js-section-header)`); + sibilings.forEach(row => row.classList.toggle('hidden')); }, /** * On click, we toggle the hidden class of @@ -68,14 +92,7 @@ export default { */ handleSectionClick(evt) { const clickedArrow = evt.currentTarget; - // toggle the arrow class - clickedArrow.classList.toggle('fa-caret-right'); - clickedArrow.classList.toggle('fa-caret-down'); - - const { section } = clickedArrow.dataset; - const sibilings = this.$el.querySelectorAll(`.js-s-${section}:not(.js-section-header)`); - - sibilings.forEach(row => row.classList.toggle('hidden')); + this.updateToggleSection(clickedArrow, clickedArrow.dataset.section); }, }, }; diff --git a/app/assets/javascripts/jobs/components/manual_variables_form.vue b/app/assets/javascripts/jobs/components/manual_variables_form.vue new file mode 100644 index 00000000000..c32a3cac7be --- /dev/null +++ b/app/assets/javascripts/jobs/components/manual_variables_form.vue @@ -0,0 +1,179 @@ +<script> +import _ from 'underscore'; +import { mapActions } from 'vuex'; +import { GlButton } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + name: 'ManualVariablesForm', + components: { + GlButton, + Icon, + }, + props: { + action: { + type: Object, + required: false, + default: null, + validator(value) { + return ( + value === null || + (_.has(value, 'path') && _.has(value, 'method') && _.has(value, 'button_title')) + ); + }, + }, + variablesSettingsUrl: { + type: String, + required: true, + default: '', + }, + }, + inputTypes: { + key: 'key', + value: 'value', + }, + i18n: { + keyPlaceholder: s__('CiVariables|Input variable key'), + valuePlaceholder: s__('CiVariables|Input variable value'), + }, + data() { + return { + variables: [], + key: '', + secretValue: '', + }; + }, + computed: { + helpText() { + return sprintf( + s__( + 'CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default', + ), + { + linkStart: `<a href="${this.variablesSettingsUrl}">`, + linkEnd: '</a>', + }, + false, + ); + }, + }, + watch: { + key(newVal) { + this.handleValueChange(newVal, this.$options.inputTypes.key); + }, + secretValue(newVal) { + this.handleValueChange(newVal, this.$options.inputTypes.value); + }, + }, + methods: { + ...mapActions(['triggerManualJob']), + handleValueChange(newValue, type) { + if (newValue !== '') { + this.createNewVariable(type); + this.resetForm(); + } + }, + createNewVariable(type) { + const newVariable = { + key: this.key, + secret_value: this.secretValue, + id: _.uniqueId(), + }; + + this.variables.push(newVariable); + + return this.$nextTick().then(() => { + this.$refs[`${this.$options.inputTypes[type]}-${newVariable.id}`][0].focus(); + }); + }, + resetForm() { + this.key = ''; + this.secretValue = ''; + }, + deleteVariable(id) { + this.variables.splice(this.variables.findIndex(el => el.id === id), 1); + }, + }, +}; +</script> +<template> + <div class="js-manual-vars-form col-12"> + <label>{{ s__('CiVariables|Variables') }}</label> + + <div class="ci-table"> + <div class="gl-responsive-table-row table-row-header pb-0 pt-0 border-0" role="row"> + <div class="table-section section-50" role="rowheader">{{ s__('CiVariables|Key') }}</div> + <div class="table-section section-50" role="rowheader">{{ s__('CiVariables|Value') }}</div> + </div> + + <div v-for="variable in variables" :key="variable.id" class="gl-responsive-table-row"> + <div class="table-section section-50"> + <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Key') }}</div> + <div class="table-mobile-content append-right-10"> + <input + :ref="`${$options.inputTypes.key}-${variable.id}`" + v-model="variable.key" + :placeholder="$options.i18n.keyPlaceholder" + class="ci-variable-body-item form-control" + /> + </div> + </div> + + <div class="table-section section-50"> + <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Value') }}</div> + <div class="table-mobile-content append-right-10"> + <input + :ref="`${$options.inputTypes.value}-${variable.id}`" + v-model="variable.secret_value" + :placeholder="$options.i18n.valuePlaceholder" + class="ci-variable-body-item form-control" + /> + </div> + </div> + + <div class="table-section section-10"> + <div class="table-mobile-header" role="rowheader"></div> + <div class="table-mobile-content justify-content-end"> + <gl-button class="btn-transparent btn-blank w-25" @click="deleteVariable(variable.id)"> + <icon name="clear" /> + </gl-button> + </div> + </div> + </div> + <div class="gl-responsive-table-row"> + <div class="table-section section-50"> + <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Key') }}</div> + <div class="table-mobile-content append-right-10"> + <input + ref="inputKey" + v-model="key" + class="js-input-key form-control" + :placeholder="$options.i18n.keyPlaceholder" + /> + </div> + </div> + + <div class="table-section section-50"> + <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Value') }}</div> + <div class="table-mobile-content append-right-10"> + <input + ref="inputSecretValue" + v-model="secretValue" + class="ci-variable-body-item form-control" + :placeholder="$options.i18n.valuePlaceholder" + /> + </div> + </div> + </div> + </div> + <div class="d-flex prepend-top-default justify-content-center"> + <p class="text-muted" v-html="helpText"></p> + </div> + <div class="d-flex justify-content-center"> + <gl-button variant="primary" @click="triggerManualJob(variables)"> + {{ action.button_title }} + </gl-button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue index e9704584c9f..06477477aad 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -73,15 +73,14 @@ export default { }, renderBlock() { return ( - this.job.merge_request || this.job.duration || - this.job.finished_data || + this.job.finished_at || this.job.erased_at || this.job.queued || + this.hasTimeout || this.job.runner || this.job.coverage || - this.job.tags.length || - this.job.cancel_path + this.job.tags.length ); }, hasArtifact() { @@ -160,7 +159,7 @@ export default { </gl-link> </div> - <div :class="{ block: renderBlock }"> + <div v-if="renderBlock" class="block"> <detail-row v-if="job.duration" :value="duration" diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js index 25132449458..add7f9b710a 100644 --- a/app/assets/javascripts/jobs/index.js +++ b/app/assets/javascripts/jobs/index.js @@ -10,15 +10,29 @@ export default () => { JobApp, }, render(createElement) { + const { + deploymentHelpUrl, + runnerHelpUrl, + runnerSettingsUrl, + variablesSettingsUrl, + endpoint, + pagePath, + logState, + buildStatus, + projectPath, + } = element.dataset; + return createElement('job-app', { props: { - deploymentHelpUrl: element.dataset.deploymentHelpUrl, - runnerHelpUrl: element.dataset.runnerHelpUrl, - runnerSettingsUrl: element.dataset.runnerSettingsUrl, - endpoint: element.dataset.endpoint, - pagePath: element.dataset.buildOptionsPagePath, - logState: element.dataset.buildOptionsLogState, - buildStatus: element.dataset.buildOptionsBuildStatus, + deploymentHelpUrl, + runnerHelpUrl, + runnerSettingsUrl, + variablesSettingsUrl, + endpoint, + pagePath, + logState, + buildStatus, + projectPath, }, }); }, diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js index 12d67a43599..a2daef96a2d 100644 --- a/app/assets/javascripts/jobs/store/actions.js +++ b/app/assets/javascripts/jobs/store/actions.js @@ -209,5 +209,19 @@ export const receiveJobsForStageError = ({ commit }) => { flash(__('An error occurred while fetching the jobs.')); }; +export const triggerManualJob = ({ state }, variables) => { + const parsedVariables = variables.map(variable => { + const copyVar = Object.assign({}, variable); + delete copyVar.id; + return copyVar; + }); + + axios + .post(state.job.status.action.path, { + job_variables_attributes: parsedVariables, + }) + .catch(() => flash(__('An error occurred while triggering the job.'))); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 3f954b43ee3..f50a6e3b19d 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -11,7 +11,7 @@ import CreateLabelDropdown from './create_label'; import flash from './flash'; import ModalStore from './boards/stores/modal_store'; import boardsStore from './boards/stores/boards_store'; -import { isEE, isScopedLabel } from '~/lib/utils/common_utils'; +import { isScopedLabel } from '~/lib/utils/common_utils'; export default class LabelsSelect { constructor(els, options = {}) { @@ -140,7 +140,7 @@ export default class LabelsSelect { labelCount = data.labels.length; // EE Specific - if (isEE) { + if (IS_EE) { /** * For Scoped labels, the last label selected with the * same key will be applied to the current issueable. @@ -311,7 +311,8 @@ export default class LabelsSelect { // We need to identify which items are actually labels if (label.id) { - selectedClass.push('label-item'); + const selectedLayoutClasses = ['d-flex', 'flex-row', 'text-break-word']; + selectedClass.push('label-item', ...selectedLayoutClasses); linkEl.dataset.labelId = label.id; } diff --git a/app/assets/javascripts/lib/utils/color_utils.js b/app/assets/javascripts/lib/utils/color_utils.js new file mode 100644 index 00000000000..07fb2915ca7 --- /dev/null +++ b/app/assets/javascripts/lib/utils/color_utils.js @@ -0,0 +1,25 @@ +/** + * Convert hex color to rgb array + * + * @param hex string + * @returns array|null + */ +export const hexToRgb = hex => { + // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") + const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; + const fullHex = hex.replace(shorthandRegex, (_m, r, g, b) => r + r + g + g + b + b); + + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(fullHex); + return result + ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] + : null; +}; + +export const textColorForBackground = backgroundColor => { + const [r, g, b] = hexToRgb(backgroundColor); + + if (r + g + b > 500) { + return '#333333'; + } + return '#FFFFFF'; +}; diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index cc5e12aa467..6e8f63a10a4 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -44,6 +44,11 @@ export const isInIssuePage = () => checkPageAndAction('issues', 'show'); export const isInMRPage = () => checkPageAndAction('merge_requests', 'show'); export const isInEpicPage = () => checkPageAndAction('epics', 'show'); +export const getCspNonceValue = () => { + const metaTag = document.querySelector('meta[name=csp-nonce]'); + return metaTag && metaTag.content; +}; + export const ajaxGet = url => axios .get(url, { @@ -51,7 +56,7 @@ export const ajaxGet = url => responseType: 'text', }) .then(({ data }) => { - $.globalEval(data); + $.globalEval(data, { nonce: getCspNonceValue() }); }); export const rstrip = val => { @@ -727,12 +732,64 @@ export const NavigationType = { }; /** - * Returns the value of `gon.ee` - * Used to check if it's the EE codebase or the CE one. + * Method to perform case-insensitive search for a string + * within multiple properties and return object containing + * properties in case there are multiple matches or `null` + * if there's no match. * - * @returns Boolean + * Eg; Suppose we want to allow user to search using for a string + * within `iid`, `title`, `url` or `reference` props of a target object; + * + * const objectToSearch = { + * "iid": 1, + * "title": "Error omnis quos consequatur ullam a vitae sed omnis libero cupiditate. &3", + * "url": "/groups/gitlab-org/-/epics/1", + * "reference": "&1", + * }; + * + * Following is how we call searchBy and the return values it will yield; + * + * - `searchBy('omnis', objectToSearch);`: This will return `{ title: ... }` as our + * query was found within title prop we only return that. + * - `searchBy('1', objectToSearch);`: This will return `{ "iid": ..., "reference": ..., "url": ... }`. + * - `searchBy('https://gitlab.com/groups/gitlab-org/-/epics/1', objectToSearch);`: + * This will return `{ "url": ... }`. + * - `searchBy('foo', objectToSearch);`: This will return `null` as no property value + * matched with our query. + * + * You can learn more about behaviour of this method by referring to tests + * within `spec/javascripts/lib/utils/common_utils_spec.js`. + * + * @param {string} query String to search for + * @param {object} searchSpace Object containing properties to search in for `query` */ -export const isEE = () => window.gon && window.gon.ee; +export const searchBy = (query = '', searchSpace = {}) => { + const targetKeys = searchSpace !== null ? Object.keys(searchSpace) : []; + + if (!query || !targetKeys.length) { + return null; + } + + const normalizedQuery = query.toLowerCase(); + const matches = targetKeys + .filter(item => { + const searchItem = `${searchSpace[item]}`.toLowerCase(); + + return ( + searchItem.indexOf(normalizedQuery) > -1 || + normalizedQuery.indexOf(searchItem) > -1 || + normalizedQuery === searchItem + ); + }) + .reduce((acc, prop) => { + const match = acc; + match[prop] = searchSpace[prop]; + + return acc; + }, {}); + + return Object.keys(matches).length ? matches : null; +}; /** * Checks if the given Label has a special syntax `::` in diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 062d21ed247..a4715789337 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -2,8 +2,7 @@ import $ from 'jquery'; import _ from 'underscore'; import timeago from 'timeago.js'; import dateFormat from 'dateformat'; -import { pluralize } from './text_utility'; -import { languageCode, s__, __ } from '../../locale'; +import { languageCode, s__, __, n__ } from '../../locale'; window.timeago = timeago; @@ -231,14 +230,10 @@ export const timeIntervalInWords = intervalInSeconds => { const secondsInteger = parseInt(intervalInSeconds, 10); const minutes = Math.floor(secondsInteger / 60); const seconds = secondsInteger - minutes * 60; - let text = ''; - - if (minutes >= 1) { - text = `${minutes} ${pluralize('minute', minutes)} ${seconds} ${pluralize('second', seconds)}`; - } else { - text = `${seconds} ${pluralize('second', seconds)}`; - } - return text; + const secondsText = n__('%d second', '%d seconds', seconds); + return minutes >= 1 + ? [n__('%d minute', '%d minutes', minutes), secondsText].join(' ') + : secondsText; }; export const dateInWords = (date, abbreviated = false, hideYear = false) => { diff --git a/app/assets/javascripts/lib/utils/forms.js b/app/assets/javascripts/lib/utils/forms.js new file mode 100644 index 00000000000..106209a2f3a --- /dev/null +++ b/app/assets/javascripts/lib/utils/forms.js @@ -0,0 +1,12 @@ +export const serializeFormEntries = entries => + entries.reduce((acc, { name, value }) => Object.assign(acc, { [name]: value }), {}); + +export const serializeForm = form => { + const fdata = new FormData(form); + const entries = Array.from(fdata.keys()).map(key => { + const val = fdata.getAll(key); + return { name: key, value: val.length === 1 ? val[0] : val }; + }); + + return serializeFormEntries(entries); +}; diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js index 37ad1676f7a..5e5d10883a3 100644 --- a/app/assets/javascripts/lib/utils/http_status.js +++ b/app/assets/javascripts/lib/utils/http_status.js @@ -19,6 +19,7 @@ const httpStatusCodes = { UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, + GONE: 410, UNPROCESSABLE_ENTITY: 422, }; diff --git a/app/assets/javascripts/lib/utils/icons_path.js b/app/assets/javascripts/lib/utils/icons_path.js new file mode 100644 index 00000000000..1a1c3c8e7b3 --- /dev/null +++ b/app/assets/javascripts/lib/utils/icons_path.js @@ -0,0 +1,3 @@ +// any import of '@gitlab/svgs/dist/icons.svg' will be overridden with this +// to avoid asset duplication between sprockets and webpack +export default gon && gon.sprite_icons; diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index cc1d85fd97d..d13fbeb5fc7 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -29,14 +29,6 @@ export const humanize = string => string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); /** - * Adds an 's' to the end of the string when count is bigger than 0 - * @param {String} str - * @param {Number} count - * @returns {String} - */ -export const pluralize = (str, count) => str + (count > 1 || count === 0 ? 's' : ''); - -/** * Replaces underscores with dashes * @param {*} str * @returns {String} @@ -44,11 +36,18 @@ export const pluralize = (str, count) => str + (count > 1 || count === 0 ? 's' : export const dasherize = str => str.replace(/[_\s]+/g, '-'); /** - * Replaces whitespaces with hyphens and converts to lower case + * Replaces whitespaces with hyphens, convert to lower case and remove non-allowed special characters * @param {String} str * @returns {String} */ -export const slugifyWithHyphens = str => str.toLowerCase().replace(/\s+/g, '-'); +export const slugify = str => { + const slug = str + .trim() + .toLowerCase() + .replace(/[^a-zA-Z0-9_.-]+/g, '-'); + + return slug === '-' ? '' : slug; +}; /** * Replaces whitespaces with underscore and converts to lower case diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 32fd0990374..7ead9d46fbb 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -1,9 +1,15 @@ import { join as joinPaths } from 'path'; +// Returns a decoded url parameter value +// - Treats '+' as '%20' +function decodeUrlParameter(val) { + return decodeURIComponent(val.replace(/\+/g, '%20')); +} + // Returns an array containing the value(s) of the // of the key passed as an argument -export function getParameterValues(sParam) { - const sPageURL = decodeURIComponent(window.location.search.substring(1)); +export function getParameterValues(sParam, url = window.location) { + const sPageURL = decodeURIComponent(new URL(url).search.substring(1)); return sPageURL.split('&').reduce((acc, urlParam) => { const sParameterName = urlParam.split('='); @@ -30,7 +36,7 @@ export function mergeUrlParams(params, url) { .forEach(part => { if (part.length) { const kv = part.split('='); - merged[decodeURIComponent(kv[0])] = decodeURIComponent(kv.slice(1).join('=')); + merged[decodeUrlParameter(kv[0])] = decodeUrlParameter(kv.slice(1).join('=')); } }); } diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 9f30a989295..39f2097c174 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -9,7 +9,11 @@ import './commons'; import './behaviors'; // lib/utils -import { handleLocationHash, addSelectOnFocusBehaviour } from './lib/utils/common_utils'; +import { + handleLocationHash, + addSelectOnFocusBehaviour, + getCspNonceValue, +} from './lib/utils/common_utils'; import { localTimeAgo } from './lib/utils/datetime_utility'; import { getLocationHash, visitUrl } from './lib/utils/url_utility'; @@ -33,10 +37,23 @@ import GlFieldErrors from './gl_field_errors'; import initUserPopovers from './user_popovers'; import { __ } from './locale'; +import 'ee_else_ce/main_ee'; + // expose jQuery as global (TODO: remove these) window.jQuery = jQuery; window.$ = jQuery; +// Add nonce to jQuery script handler +jQuery.ajaxSetup({ + converters: { + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings, func-names + 'text script': function(text) { + jQuery.globalEval(text, { nonce: getCspNonceValue() }); + return text; + }, + }, +}); + // inject test utilities if necessary if (process.env.NODE_ENV !== 'production' && gon && gon.test_env) { $.fx.off = true; @@ -105,6 +122,7 @@ function deferredInitialisation() { .then(() => { $('select.select2').select2({ width: 'resolve', + minimumResultsForSearch: 10, dropdownAutoWidth: true, }); @@ -119,11 +137,15 @@ function deferredInitialisation() { .catch(() => {}); } + const glTooltipDelay = localStorage.getItem('gl-tooltip-delay'); + const delay = glTooltipDelay ? JSON.parse(glTooltipDelay) : 0; + // Initialize tooltips $body.tooltip({ selector: '.has-tooltip, [data-toggle="tooltip"]', trigger: 'hover', boundary: 'viewport', + delay, }); // Initialize popovers diff --git a/app/assets/javascripts/main_ee.js b/app/assets/javascripts/main_ee.js new file mode 100644 index 00000000000..84d74775163 --- /dev/null +++ b/app/assets/javascripts/main_ee.js @@ -0,0 +1 @@ +// This is an empty file to satisfy ee_else_ce import for the EE main entry point diff --git a/app/assets/javascripts/manual_ordering.js b/app/assets/javascripts/manual_ordering.js index e16ddbfef7e..29a0e5a904a 100644 --- a/app/assets/javascripts/manual_ordering.js +++ b/app/assets/javascripts/manual_ordering.js @@ -21,7 +21,7 @@ const updateIssue = (url, issueList, { move_before_id, move_after_id }) => const initManualOrdering = () => { const issueList = document.querySelector('.manual-ordering'); - if (!issueList || !(gon.features && gon.features.manualSorting)) { + if (!issueList || !(gon.current_user_id > 0)) { return; } diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js index af2697444f2..d719fd8748d 100644 --- a/app/assets/javascripts/members.js +++ b/app/assets/javascripts/members.js @@ -17,6 +17,8 @@ export default class Members { } dropdownClicked(options) { + options.e.preventDefault(); + this.formSubmit(null, options.$el); } diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue index 81773bd140e..cac10474d06 100644 --- a/app/assets/javascripts/monitoring/components/charts/area.vue +++ b/app/assets/javascripts/monitoring/components/charts/area.vue @@ -8,9 +8,13 @@ import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import Icon from '~/vue_shared/components/icon.vue'; import { chartHeight, graphTypes, lineTypes } from '../../constants'; import { makeDataSeries } from '~/helpers/monitor_helper'; +import { graphDataValidatorForValues } from '../../utils'; let debouncedResize; +// TODO: Remove this component in favor of the more general time_series.vue +// Please port all changes here to time_series.vue as well. + export default { components: { GlAreaChart, @@ -23,19 +27,7 @@ export default { graphData: { type: Object, required: true, - validator(data) { - return ( - 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 - ); - }, + validator: graphDataValidatorForValues.bind(null, false), }, containerWidth: { type: Number, @@ -48,7 +40,18 @@ export default { }, projectPath: { type: String, - required: true, + required: false, + default: () => '', + }, + showBorder: { + type: Boolean, + required: false, + default: () => false, + }, + singleEmbed: { + type: Boolean, + required: false, + default: false, }, thresholds: { type: Array, @@ -123,7 +126,7 @@ export default { }, }, series: this.scatterSeries, - dataZoom: this.dataZoomConfig, + dataZoom: [this.dataZoomConfig], }; }, dataZoomConfig() { @@ -245,52 +248,57 @@ export default { </script> <template> - <div class="prometheus-graph col-12 col-lg-6"> - <div class="prometheus-graph-header"> - <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5> - <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div> - </div> - <gl-area-chart - ref="areaChart" - v-bind="$attrs" - :data="chartData" - :option="chartOptions" - :format-tooltip-text="formatTooltipText" - :thresholds="thresholds" - :width="width" - :height="height" - @updated="onChartUpdated" - > - <template v-if="tooltip.isDeployment"> - <template slot="tooltipTitle"> - {{ __('Deployed') }} - </template> - <div slot="tooltipContent" class="d-flex align-items-center"> - <icon name="commit" class="mr-2" /> - <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link> - </div> - </template> - <template v-else> - <template slot="tooltipTitle"> - <div class="text-nowrap"> - {{ tooltip.title }} + <div + class="prometheus-graph col-12" + :class="[showBorder ? 'p-2' : 'p-0', { 'col-lg-6': !singleEmbed }]" + > + <div :class="{ 'prometheus-graph-embed w-100 p-3': showBorder }"> + <div class="prometheus-graph-header"> + <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5> + <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div> + </div> + <gl-area-chart + ref="areaChart" + v-bind="$attrs" + :data="chartData" + :option="chartOptions" + :format-tooltip-text="formatTooltipText" + :thresholds="thresholds" + :width="width" + :height="height" + @updated="onChartUpdated" + > + <template v-if="tooltip.isDeployment"> + <template slot="tooltipTitle"> + {{ __('Deployed') }} + </template> + <div slot="tooltipContent" class="d-flex align-items-center"> + <icon name="commit" class="mr-2" /> + <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link> </div> </template> - <template slot="tooltipContent"> - <div - v-for="(content, key) in tooltip.content" - :key="key" - class="d-flex justify-content-between" - > - <gl-chart-series-label :color="isMultiSeries ? content.color : ''"> - {{ content.name }} - </gl-chart-series-label> - <div class="prepend-left-32"> - {{ content.value }} + <template v-else> + <template slot="tooltipTitle"> + <div class="text-nowrap"> + {{ tooltip.title }} </div> - </div> + </template> + <template slot="tooltipContent"> + <div + v-for="(content, key) in tooltip.content" + :key="key" + class="d-flex justify-content-between" + > + <gl-chart-series-label :color="isMultiSeries ? content.color : ''"> + {{ content.name }} + </gl-chart-series-label> + <div class="prepend-left-32"> + {{ content.value }} + </div> + </div> + </template> </template> - </template> - </gl-area-chart> + </gl-area-chart> + </div> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/charts/column.vue b/app/assets/javascripts/monitoring/components/charts/column.vue index 05a2036f4c3..83136d43479 100644 --- a/app/assets/javascripts/monitoring/components/charts/column.vue +++ b/app/assets/javascripts/monitoring/components/charts/column.vue @@ -4,6 +4,7 @@ import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import { chartHeight } from '../../constants'; import { makeDataSeries } from '~/helpers/monitor_helper'; +import { graphDataValidatorForValues } from '../../utils'; export default { components: { @@ -14,23 +15,11 @@ export default { graphData: { type: Object, required: true, - validator(data) { - return ( - 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 - ); - }, - containerWidth: { - type: Number, - required: true, - }, + validator: graphDataValidatorForValues.bind(null, false), + }, + containerWidth: { + type: Number, + required: true, }, }, data() { diff --git a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue new file mode 100644 index 00000000000..73682adc4ee --- /dev/null +++ b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue @@ -0,0 +1,41 @@ +<script> +import chartEmptyStateIllustration from '@gitlab/svgs/dist/illustrations/chart-empty-state.svg'; +import { chartHeight } from '../../constants'; + +export default { + props: { + graphTitle: { + type: String, + required: true, + }, + }, + data() { + return { + height: chartHeight, + }; + }, + computed: { + svgContainerStyle() { + return { + height: `${this.height}px`, + }; + }, + }, + created() { + this.chartEmptyStateIllustration = chartEmptyStateIllustration; + }, +}; +</script> +<template> + <div class="prometheus-graph col-12 col-lg-6 d-flex flex-column justify-content-center"> + <div class="prometheus-graph-header"> + <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphTitle }}</h5> + </div> + <div + class="prepend-top-8 svg-w-100 d-flex align-items-center" + :style="svgContainerStyle" + v-html="chartEmptyStateIllustration" + ></div> + <h5 class="text-center prepend-top-8">{{ __('No data to display') }}</h5> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/charts/single_stat.vue b/app/assets/javascripts/monitoring/components/charts/single_stat.vue index b03a6ca1806..7428b27a9c3 100644 --- a/app/assets/javascripts/monitoring/components/charts/single_stat.vue +++ b/app/assets/javascripts/monitoring/components/charts/single_stat.vue @@ -1,5 +1,7 @@ <script> import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { roundOffFloat } from '~/lib/utils/common_utils'; +import { graphDataValidatorForValues } from '../../utils'; export default { components: { @@ -7,22 +9,21 @@ export default { }, inheritAttrs: false, props: { - title: { - type: String, - required: true, - }, - value: { - type: Number, - required: true, - }, - unit: { - type: String, + graphData: { + type: Object, required: true, + validator: graphDataValidatorForValues.bind(null, true), }, }, computed: { - valueWithUnit() { - return `${this.value}${this.unit}`; + queryInfo() { + return this.graphData.queries[0]; + }, + engineeringNotation() { + return `${roundOffFloat(this.queryInfo.result[0].value[1], 1)}${this.queryInfo.unit}`; + }, + graphTitle() { + return this.queryInfo.label; }, }, }; @@ -30,8 +31,8 @@ export default { <template> <div class="prometheus-graph col-12 col-lg-6"> <div class="prometheus-graph-header"> - <h5 ref="graphTitle" class="prometheus-graph-title">{{ title }}</h5> + <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphTitle }}</h5> </div> - <gl-single-stat :value="valueWithUnit" :title="title" variant="success" /> + <gl-single-stat :value="engineeringNotation" :title="graphTitle" variant="success" /> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue new file mode 100644 index 00000000000..02e7a7ba0a6 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -0,0 +1,342 @@ +<script> +import { __ } from '~/locale'; +import { mapState } from 'vuex'; +import { GlLink, GlButton } from '@gitlab/ui'; +import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; +import dateFormat from 'dateformat'; +import { debounceByAnimationFrame, roundOffFloat } from '~/lib/utils/common_utils'; +import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; +import Icon from '~/vue_shared/components/icon.vue'; +import { chartHeight, graphTypes, lineTypes, symbolSizes, dateFormats } from '../../constants'; +import { makeDataSeries } from '~/helpers/monitor_helper'; +import { graphDataValidatorForValues } from '../../utils'; + +let debouncedResize; + +export default { + components: { + GlAreaChart, + GlLineChart, + GlButton, + GlChartSeriesLabel, + GlLink, + Icon, + }, + inheritAttrs: false, + props: { + graphData: { + type: Object, + required: true, + validator: graphDataValidatorForValues.bind(null, false), + }, + containerWidth: { + type: Number, + required: true, + }, + deploymentData: { + type: Array, + required: false, + default: () => [], + }, + projectPath: { + type: String, + required: false, + default: '', + }, + showBorder: { + type: Boolean, + required: false, + default: false, + }, + singleEmbed: { + type: Boolean, + required: false, + default: false, + }, + thresholds: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + return { + tooltip: { + title: '', + content: [], + commitUrl: '', + isDeployment: false, + sha: '', + }, + width: 0, + height: chartHeight, + svgs: {}, + primaryColor: null, + }; + }, + computed: { + ...mapState('monitoringDashboard', ['exportMetricsToCsvEnabled']), + chartData() { + // Transforms & supplements query data to render appropriate labels & styles + // Input: [{ queryAttributes1 }, { queryAttributes2 }] + // Output: [{ seriesAttributes1 }, { seriesAttributes2 }] + return this.graphData.queries.reduce((acc, query) => { + const { appearance } = query; + const lineType = + appearance && appearance.line && appearance.line.type + ? appearance.line.type + : lineTypes.default; + const lineWidth = + appearance && appearance.line && appearance.line.width + ? appearance.line.width + : undefined; + const areaStyle = { + opacity: + appearance && appearance.area && typeof appearance.area.opacity === 'number' + ? appearance.area.opacity + : undefined, + }; + + const series = makeDataSeries(query.result, { + name: this.formatLegendLabel(query), + lineStyle: { + type: lineType, + width: lineWidth, + }, + showSymbol: false, + areaStyle: this.graphData.type === 'area-chart' ? areaStyle : undefined, + }); + + return acc.concat(series); + }, []); + }, + chartOptions() { + return { + xAxis: { + name: __('Time'), + type: 'time', + axisLabel: { + formatter: date => dateFormat(date, dateFormats.timeOfDay), + }, + axisPointer: { + snap: true, + }, + }, + yAxis: { + name: this.yAxisLabel, + axisLabel: { + formatter: num => roundOffFloat(num, 3).toString(), + }, + }, + series: this.scatterSeries, + dataZoom: this.dataZoomConfig, + }; + }, + dataZoomConfig() { + const handleIcon = this.svgs['scroll-handle']; + + return handleIcon ? { handleIcon } : {}; + }, + earliestDatapoint() { + return this.chartData.reduce((acc, series) => { + const { data } = series; + const { length } = data; + if (!length) { + return acc; + } + + const [first] = data[0]; + const [last] = data[length - 1]; + const seriesEarliest = first < last ? first : last; + + return seriesEarliest < acc || acc === null ? seriesEarliest : acc; + }, null); + }, + glChartComponent() { + const chartTypes = { + 'area-chart': GlAreaChart, + 'line-chart': GlLineChart, + }; + return chartTypes[this.graphData.type] || GlAreaChart; + }, + isMultiSeries() { + return this.tooltip.content.length > 1; + }, + recentDeployments() { + return this.deploymentData.reduce((acc, deployment) => { + if (deployment.created_at >= this.earliestDatapoint) { + const { id, created_at, sha, ref, tag } = deployment; + acc.push({ + id, + createdAt: created_at, + sha, + commitUrl: `${this.projectPath}/commit/${sha}`, + tag, + tagUrl: tag ? `${this.tagsPath}/${ref.name}` : null, + ref: ref.name, + showDeploymentFlag: false, + }); + } + + return acc; + }, []); + }, + scatterSeries() { + return { + type: graphTypes.deploymentData, + data: this.recentDeployments.map(deployment => [deployment.createdAt, 0]), + symbol: this.svgs.rocket, + symbolSize: symbolSizes.default, + itemStyle: { + color: this.primaryColor, + }, + }; + }, + yAxisLabel() { + return `${this.graphData.y_label}`; + }, + csvText() { + const chartData = this.chartData[0].data; + const header = `timestamp,${this.graphData.y_label}\r\n`; // eslint-disable-line @gitlab/i18n/no-non-i18n-strings + return chartData.reduce((csv, data) => { + const row = data.join(','); + return `${csv}${row}\r\n`; + }, header); + }, + downloadLink() { + const data = new Blob([this.csvText], { type: 'text/plain' }); + return window.URL.createObjectURL(data); + }, + }, + watch: { + containerWidth: 'onResize', + }, + beforeDestroy() { + window.removeEventListener('resize', debouncedResize); + }, + created() { + debouncedResize = debounceByAnimationFrame(this.onResize); + window.addEventListener('resize', debouncedResize); + this.setSvg('rocket'); + this.setSvg('scroll-handle'); + }, + methods: { + formatLegendLabel(query) { + return `${query.label}`; + }, + formatTooltipText(params) { + this.tooltip.title = dateFormat(params.value, dateFormats.default); + this.tooltip.content = []; + params.seriesData.forEach(dataPoint => { + const [xVal, yVal] = dataPoint.value; + this.tooltip.isDeployment = dataPoint.componentSubType === graphTypes.deploymentData; + if (this.tooltip.isDeployment) { + const [deploy] = this.recentDeployments.filter( + deployment => deployment.createdAt === xVal, + ); + this.tooltip.sha = deploy.sha.substring(0, 8); + this.tooltip.commitUrl = deploy.commitUrl; + } else { + const { seriesName, color } = dataPoint; + const value = yVal.toFixed(3); + this.tooltip.content.push({ + name: seriesName, + value, + color, + }); + } + }); + }, + setSvg(name) { + getSvgIconPathContent(name) + .then(path => { + if (path) { + this.$set(this.svgs, name, `path://${path}`); + } + }) + .catch(e => { + // eslint-disable-next-line no-console, @gitlab/i18n/no-non-i18n-strings + console.error('SVG could not be rendered correctly: ', e); + }); + }, + onChartUpdated(chart) { + [this.primaryColor] = chart.getOption().color; + }, + onResize() { + if (!this.$refs.chart) return; + const { width } = this.$refs.chart.$el.getBoundingClientRect(); + this.width = width; + }, + }, +}; +</script> + +<template> + <div + class="prometheus-graph col-12" + :class="[showBorder ? 'p-2' : 'p-0', { 'col-lg-6': !singleEmbed }]" + > + <div :class="{ 'prometheus-graph-embed w-100 p-3': showBorder }"> + <div class="prometheus-graph-header"> + <h5 class="prometheus-graph-title js-graph-title">{{ graphData.title }}</h5> + <gl-button + v-if="exportMetricsToCsvEnabled" + :href="downloadLink" + :title="__('Download CSV')" + :aria-label="__('Download CSV')" + style="margin-left: 200px;" + download="chart_metrics.csv" + > + {{ __('Download CSV') }} + </gl-button> + <div class="prometheus-graph-widgets js-graph-widgets"> + <slot></slot> + </div> + </div> + + <component + :is="glChartComponent" + ref="chart" + v-bind="$attrs" + :data="chartData" + :option="chartOptions" + :format-tooltip-text="formatTooltipText" + :thresholds="thresholds" + :width="width" + :height="height" + @updated="onChartUpdated" + > + <template v-if="tooltip.isDeployment"> + <template slot="tooltipTitle"> + {{ __('Deployed') }} + </template> + <div slot="tooltipContent" class="d-flex align-items-center"> + <icon name="commit" class="mr-2" /> + <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link> + </div> + </template> + <template v-else> + <template slot="tooltipTitle"> + <div class="text-nowrap"> + {{ tooltip.title }} + </div> + </template> + <template slot="tooltipContent"> + <div + v-for="(content, key) in tooltip.content" + :key="key" + class="d-flex justify-content-between" + > + <gl-chart-series-label :color="isMultiSeries ? content.color : ''"> + {{ content.name }} + </gl-chart-series-label> + <div class="prepend-left-32"> + {{ content.value }} + </div> + </div> + </template> + </template> + </component> + </div> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 2cbda8ea05d..d330ceb836c 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -1,34 +1,46 @@ <script> -import { GlButton, GlDropdown, GlDropdownItem, GlModal, GlModalDirective } from '@gitlab/ui'; +import { + GlButton, + GlDropdown, + GlDropdownItem, + GlFormGroup, + GlModal, + GlModalDirective, + GlTooltipDirective, +} from '@gitlab/ui'; import _ from 'underscore'; import { mapActions, mapState } from 'vuex'; -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; -import '~/vue_shared/mixins/is_ee'; -import { getParameterValues } from '~/lib/utils/url_utility'; +import { getParameterValues, mergeUrlParams } from '~/lib/utils/url_utility'; import invalidUrl from '~/lib/utils/invalid_url'; -import MonitorAreaChart from './charts/area.vue'; +import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; +import MonitorTimeSeriesChart from './charts/time_series.vue'; +import MonitorSingleStatChart from './charts/single_stat.vue'; import GraphGroup from './graph_group.vue'; import EmptyState from './empty_state.vue'; -import { timeWindows, timeWindowsKeyNames } from '../constants'; -import { getTimeDiff } from '../utils'; +import { sidebarAnimationDuration, timeWindows } from '../constants'; +import { getTimeDiff, getTimeWindow } from '../utils'; -const sidebarAnimationDuration = 150; let sidebarMutationObserver; export default { components: { - MonitorAreaChart, + MonitorTimeSeriesChart, + MonitorSingleStatChart, + PanelType, GraphGroup, EmptyState, Icon, GlButton, GlDropdown, GlDropdownItem, + GlFormGroup, GlModal, }, directives: { - GlModalDirective, + GlModal: GlModalDirective, + GlTooltip: GlTooltipDirective, }, props: { externalDashboardUrl: { @@ -124,6 +136,21 @@ export default { required: false, default: '', }, + smallEmptyState: { + type: Boolean, + required: false, + default: false, + }, + alertsEndpoint: { + type: String, + required: false, + default: null, + }, + prometheusAlertsAvailable: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -132,6 +159,7 @@ export default { selectedTimeWindow: '', selectedTimeWindowKey: '', formIsValid: null, + timeWindows: {}, }; }, computed: { @@ -148,12 +176,19 @@ export default { 'useDashboardEndpoint', 'allDashboards', 'multipleDashboardsEnabled', + 'additionalPanelTypesEnabled', ]), - groupsWithData() { - return this.groups.filter(group => this.chartsWithData(group.metrics).length > 0); + firstDashboard() { + return this.allDashboards[0] || {}; }, selectedDashboardText() { - return this.currentDashboard || (this.allDashboards[0] && this.allDashboards[0].display_name); + return this.currentDashboard || this.firstDashboard.display_name; + }, + addingMetricsAvailable() { + return IS_EE && this.canAddMetrics && !this.showEmptyState; + }, + alertWidgetAvailable() { + return IS_EE && this.prometheusAlertsAvailable && this.alertsEndpoint; }, }, created() { @@ -163,18 +198,8 @@ export default { deploymentsEndpoint: this.deploymentsEndpoint, dashboardEndpoint: this.dashboardEndpoint, currentDashboard: this.currentDashboard, + projectPath: this.projectPath, }); - - this.timeWindows = timeWindows; - this.selectedTimeWindowKey = - _.escape(getParameterValues('time_window')[0]) || timeWindowsKeyNames.eightHours; - - // Set default time window if the selectedTimeWindowKey is bogus - if (!Object.keys(this.timeWindows).includes(this.selectedTimeWindowKey)) { - this.selectedTimeWindowKey = timeWindowsKeyNames.eightHours; - } - - this.selectedTimeWindow = this.timeWindows[this.selectedTimeWindowKey]; }, beforeDestroy() { if (sidebarMutationObserver) { @@ -185,7 +210,20 @@ export default { if (!this.hasMetrics) { this.setGettingStartedEmptyState(); } else { - this.fetchData(getTimeDiff(this.selectedTimeWindow)); + const defaultRange = getTimeDiff(); + const start = getParameterValues('start')[0] || defaultRange.start; + const end = getParameterValues('end')[0] || defaultRange.end; + + const range = { + start, + end, + }; + + this.timeWindows = timeWindows; + this.selectedTimeWindowKey = getTimeWindow(range); + this.selectedTimeWindow = this.timeWindows[this.selectedTimeWindowKey]; + + this.fetchData(range); sidebarMutationObserver = new MutationObserver(this.onSidebarMutation); sidebarMutationObserver.observe(document.querySelector('.layout-page'), { @@ -210,6 +248,21 @@ export default { chart.metrics.some(metric => this.metricsWithData.includes(metric.metric_id)), ); }, + csvText(graphData) { + const chartData = graphData.queries[0].result[0].values; + const yLabel = graphData.y_label; + const header = `timestamp,${yLabel}\r\n`; // eslint-disable-line @gitlab/i18n/no-non-i18n-strings + return chartData.reduce((csv, data) => { + const row = data.join(','); + return `${csv}${row}\r\n`; + }, header); + }, + downloadCsv(graphData) { + const data = new Blob([this.csvText(graphData)], { type: 'text/plain' }); + return window.URL.createObjectURL(data); + }, + // TODO: BEGIN, Duplicated code with panel_type until feature flag is removed + // Issue number: https://gitlab.com/gitlab-org/gitlab-ce/issues/63845 getGraphAlerts(queries) { if (!this.allAlerts) return {}; const metricIdsForChart = queries.map(q => q.metricId); @@ -218,6 +271,15 @@ export default { getGraphAlertValues(queries) { return Object.values(this.getGraphAlerts(queries)); }, + showToast() { + this.$toast.show(__('Link copied to clipboard')); + }, + // TODO: END + generateLink(group, title, yLabel) { + const dashboard = this.currentDashboard || this.firstDashboard.path; + const params = _.pick({ dashboard, group, title, y_label: yLabel }, value => value != null); + return mergeUrlParams(params, window.location.href); + }, hideAddMetricModal() { this.$refs.addMetricModal.hide(); }, @@ -236,7 +298,11 @@ export default { return this.timeWindows[key] === this.selectedTimeWindow; }, setTimeWindowParameter(key) { - return `?time_window=${key}`; + const { start, end } = getTimeDiff(key); + return `?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`; + }, + groupHasData(group) { + return this.chartsWithData(group.metrics).length > 0; }, }, addMetric: { @@ -248,132 +314,209 @@ export default { <template> <div class="prometheus-graphs"> - <div class="gl-p-3 border-bottom bg-gray-light d-flex justify-content-between"> - <div - v-if="environmentsEndpoint" - class="dropdowns d-flex align-items-center justify-content-between" - > - <div v-if="multipleDashboardsEnabled" class="d-flex align-items-center"> - <label class="mb-0">{{ __('Dashboard') }}</label> - <gl-dropdown - class="ml-2 mr-3 js-dashboards-dropdown" - toggle-class="dropdown-menu-toggle" - :text="selectedDashboardText" + <div class="gl-p-3 pb-0 border-bottom bg-gray-light"> + <div class="row"> + <template v-if="environmentsEndpoint"> + <gl-form-group + v-if="multipleDashboardsEnabled" + :label="__('Dashboard')" + label-size="sm" + label-for="monitor-dashboards-dropdown" + class="col-sm-12 col-md-4 col-lg-2" > - <gl-dropdown-item - v-for="dashboard in allDashboards" - :key="dashboard.path" - :active="dashboard.path === currentDashboard" - active-class="is-active" - :href="`?dashboard=${dashboard.path}`" + <gl-dropdown + id="monitor-dashboards-dropdown" + class="mb-0 d-flex js-dashboards-dropdown" + toggle-class="dropdown-menu-toggle" + :text="selectedDashboardText" > - {{ dashboard.display_name || dashboard.path }} - </gl-dropdown-item> - </gl-dropdown> - </div> - <div class="d-flex align-items-center"> - <strong>{{ s__('Metrics|Environment') }}</strong> - <gl-dropdown - class="prepend-left-10 js-environments-dropdown" - toggle-class="dropdown-menu-toggle" - :text="currentEnvironmentName" - :disabled="environments.length === 0" + <gl-dropdown-item + v-for="dashboard in allDashboards" + :key="dashboard.path" + :active="dashboard.path === currentDashboard" + active-class="is-active" + :href="`?dashboard=${dashboard.path}`" + >{{ dashboard.display_name || dashboard.path }}</gl-dropdown-item + > + </gl-dropdown> + </gl-form-group> + + <gl-form-group + :label="s__('Metrics|Environment')" + label-size="sm" + label-for="monitor-environments-dropdown" + class="col-sm-6 col-md-4 col-lg-2" > - <gl-dropdown-item - v-for="environment in environments" - :key="environment.id" - :active="environment.name === currentEnvironmentName" - active-class="is-active" - :href="environment.metrics_path" - >{{ environment.name }}</gl-dropdown-item + <gl-dropdown + id="monitor-environments-dropdown" + class="mb-0 d-flex js-environments-dropdown" + toggle-class="dropdown-menu-toggle" + :text="currentEnvironmentName" + :disabled="environments.length === 0" > - </gl-dropdown> - </div> - <div v-if="!showEmptyState" class="d-flex align-items-center prepend-left-8"> - <strong>{{ s__('Metrics|Show last') }}</strong> - <gl-dropdown - class="prepend-left-10 js-time-window-dropdown" - toggle-class="dropdown-menu-toggle" - :text="selectedTimeWindow" + <gl-dropdown-item + v-for="environment in environments" + :key="environment.id" + :active="environment.name === currentEnvironmentName" + active-class="is-active" + :href="environment.metrics_path" + >{{ environment.name }}</gl-dropdown-item + > + </gl-dropdown> + </gl-form-group> + + <gl-form-group + v-if="!showEmptyState" + :label="s__('Metrics|Show last')" + label-size="sm" + label-for="monitor-time-window-dropdown" + class="col-sm-6 col-md-4 col-lg-2" > - <gl-dropdown-item - v-for="(value, key) in timeWindows" - :key="key" - :active="activeTimeWindow(key)" - :href="setTimeWindowParameter(key)" - active-class="active" - >{{ value }}</gl-dropdown-item + <gl-dropdown + id="monitor-time-window-dropdown" + class="mb-0 d-flex js-time-window-dropdown" + toggle-class="dropdown-menu-toggle" + :text="selectedTimeWindow" > - </gl-dropdown> - </div> - </div> - <div class="d-flex"> - <div v-if="isEE && canAddMetrics && !showEmptyState"> - <gl-button - v-gl-modal-directive="$options.addMetric.modalId" - class="js-add-metric-button text-success border-success" - >{{ $options.addMetric.title }}</gl-button - > - <gl-modal - ref="addMetricModal" - :modal-id="$options.addMetric.modalId" - :title="$options.addMetric.title" - > - <form ref="customMetricsForm" :action="customMetricsPath" method="post"> - <custom-metrics-form-fields - :validate-query-path="validateQueryPath" - form-operation="post" - @formValidation="setFormValidity" - /> - </form> - <div slot="modal-footer"> - <gl-button @click="hideAddMetricModal">{{ __('Cancel') }}</gl-button> - <gl-button - :disabled="!formIsValid" - variant="success" - @click="submitCustomMetricsForm" - >{{ __('Save changes') }}</gl-button + <gl-dropdown-item + v-for="(value, key) in timeWindows" + :key="key" + :active="activeTimeWindow(key)" + :href="setTimeWindowParameter(key)" + active-class="active" + >{{ value }}</gl-dropdown-item > - </div> - </gl-modal> - </div> - <gl-button - v-if="externalDashboardUrl.length" - class="js-external-dashboard-link prepend-left-8" - variant="primary" - :href="externalDashboardUrl" - target="_blank" + </gl-dropdown> + </gl-form-group> + </template> + + <gl-form-group + v-if="addingMetricsAvailable || externalDashboardUrl.length" + label-for="prometheus-graphs-dropdown-buttons" + class="dropdown-buttons col-lg d-lg-flex align-items-end" > - {{ __('View full dashboard') }} - <icon name="external-link" /> - </gl-button> + <div id="prometheus-graphs-dropdown-buttons"> + <gl-button + v-if="addingMetricsAvailable" + v-gl-modal="$options.addMetric.modalId" + class="mr-2 mt-1 js-add-metric-button text-success border-success" + > + {{ $options.addMetric.title }} + </gl-button> + <gl-modal + v-if="addingMetricsAvailable" + ref="addMetricModal" + :modal-id="$options.addMetric.modalId" + :title="$options.addMetric.title" + > + <form ref="customMetricsForm" :action="customMetricsPath" method="post"> + <custom-metrics-form-fields + :validate-query-path="validateQueryPath" + form-operation="post" + @formValidation="setFormValidity" + /> + </form> + <div slot="modal-footer"> + <gl-button @click="hideAddMetricModal">{{ __('Cancel') }}</gl-button> + <gl-button + :disabled="!formIsValid" + variant="success" + @click="submitCustomMetricsForm" + > + {{ __('Save changes') }} + </gl-button> + </div> + </gl-modal> + + <gl-button + v-if="externalDashboardUrl.length" + class="mt-1 js-external-dashboard-link" + variant="primary" + :href="externalDashboardUrl" + target="_blank" + rel="noopener noreferrer" + > + {{ __('View full dashboard') }} + <icon name="external-link" /> + </gl-button> + </div> + </gl-form-group> </div> </div> + <div v-if="!showEmptyState"> <graph-group - v-for="(groupData, index) in groupsWithData" - :key="index" + v-for="(groupData, index) in groups" + :key="`${groupData.group}.${groupData.priority}`" :name="groupData.group" :show-panels="showPanels" + :collapse-group="groupHasData(groupData)" > - <monitor-area-chart - v-for="(graphData, graphIndex) in chartsWithData(groupData.metrics)" - :key="graphIndex" - :project-path="projectPath" - :graph-data="graphData" - :deployment-data="deploymentData" - :thresholds="getGraphAlertValues(graphData.queries)" - :container-width="elWidth" - group-id="monitor-area-chart" - > - <alert-widget - v-if="isEE && prometheusAlertsAvailable && alertsEndpoint && graphData" + <template v-if="additionalPanelTypesEnabled"> + <panel-type + v-for="(graphData, graphIndex) in groupData.metrics" + :key="`panel-type-${graphIndex}`" + :clipboard-text="generateLink(groupData.group, graphData.title, graphData.y_label)" + :graph-data="graphData" + :dashboard-width="elWidth" :alerts-endpoint="alertsEndpoint" - :relevant-queries="graphData.queries" - :alerts-to-manage="getGraphAlerts(graphData.queries)" - @setAlerts="setAlerts" + :prometheus-alerts-available="prometheusAlertsAvailable" + :index="`${index}-${graphIndex}`" /> - </monitor-area-chart> + </template> + <template v-else> + <monitor-time-series-chart + v-for="(graphData, graphIndex) in chartsWithData(groupData.metrics)" + :key="graphIndex" + :graph-data="graphData" + :deployment-data="deploymentData" + :thresholds="getGraphAlertValues(graphData.queries)" + :container-width="elWidth" + :project-path="projectPath" + group-id="monitor-time-series-chart" + > + <div class="d-flex align-items-center"> + <alert-widget + v-if="alertWidgetAvailable && graphData" + :modal-id="`alert-modal-${index}-${graphIndex}`" + :alerts-endpoint="alertsEndpoint" + :relevant-queries="graphData.queries" + :alerts-to-manage="getGraphAlerts(graphData.queries)" + @setAlerts="setAlerts" + /> + <gl-dropdown + v-gl-tooltip + class="mx-2" + toggle-class="btn btn-transparent border-0" + :right="true" + :no-caret="true" + :title="__('More actions')" + > + <template slot="button-content"> + <icon name="ellipsis_v" class="text-secondary" /> + </template> + <gl-dropdown-item :href="downloadCsv(graphData)" download="chart_metrics.csv"> + {{ __('Download CSV') }} + </gl-dropdown-item> + <gl-dropdown-item + class="js-chart-link" + :data-clipboard-text=" + generateLink(groupData.group, graphData.title, graphData.y_label) + " + @click="showToast" + > + {{ __('Generate link to chart') }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="alertWidgetAvailable" + v-gl-modal="`alert-modal-${index}-${graphIndex}`" + > + {{ __('Alerts') }} + </gl-dropdown-item> + </gl-dropdown> + </div> + </monitor-time-series-chart> + </template> </graph-group> </div> <empty-state @@ -386,6 +529,7 @@ export default { :empty-loading-svg-path="emptyLoadingSvgPath" :empty-no-data-svg-path="emptyNoDataSvgPath" :empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath" + :compact="smallEmptyState" /> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue new file mode 100644 index 00000000000..b516a82c170 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/embed.vue @@ -0,0 +1,107 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { getParameterValues, removeParams } from '~/lib/utils/url_utility'; +import GraphGroup from './graph_group.vue'; +import MonitorTimeSeriesChart from './charts/time_series.vue'; +import { sidebarAnimationDuration } from '../constants'; +import { getTimeDiff } from '../utils'; + +let sidebarMutationObserver; + +export default { + components: { + GraphGroup, + MonitorTimeSeriesChart, + }, + props: { + dashboardUrl: { + type: String, + required: true, + }, + }, + data() { + const defaultRange = getTimeDiff(); + const start = getParameterValues('start', this.dashboardUrl)[0] || defaultRange.start; + const end = getParameterValues('end', this.dashboardUrl)[0] || defaultRange.end; + + const params = { + start, + end, + }; + + return { + params, + elWidth: 0, + }; + }, + computed: { + ...mapState('monitoringDashboard', ['groups', 'metricsWithData']), + charts() { + const groupWithMetrics = this.groups.find(group => + group.metrics.find(chart => this.chartHasData(chart)), + ) || { metrics: [] }; + + return groupWithMetrics.metrics.filter(chart => this.chartHasData(chart)); + }, + isSingleChart() { + return this.charts.length === 1; + }, + }, + mounted() { + this.setInitialState(); + this.fetchMetricsData(this.params); + sidebarMutationObserver = new MutationObserver(this.onSidebarMutation); + sidebarMutationObserver.observe(document.querySelector('.layout-page'), { + attributes: true, + childList: false, + subtree: false, + }); + }, + beforeDestroy() { + if (sidebarMutationObserver) { + sidebarMutationObserver.disconnect(); + } + }, + methods: { + ...mapActions('monitoringDashboard', [ + 'fetchMetricsData', + 'setEndpoints', + 'setFeatureFlags', + 'setShowErrorBanner', + ]), + chartHasData(chart) { + return chart.metrics.some(metric => this.metricsWithData.includes(metric.metric_id)); + }, + onSidebarMutation() { + setTimeout(() => { + this.elWidth = this.$el.clientWidth; + }, sidebarAnimationDuration); + }, + setInitialState() { + this.setFeatureFlags({ + prometheusEndpointEnabled: true, + }); + this.setEndpoints({ + dashboardEndpoint: removeParams(['start', 'end'], this.dashboardUrl), + }); + this.setShowErrorBanner(false); + }, + }, +}; +</script> +<template> + <div class="metrics-embed" :class="{ 'd-inline-flex col-lg-6 p-0': isSingleChart }"> + <div v-if="charts.length" class="row w-100 m-n2 pb-4"> + <monitor-time-series-chart + v-for="graphData in charts" + :key="graphData.title" + :graph-data="graphData" + :container-width="elWidth" + group-id="monitor-area-chart" + :project-path="null" + :show-border="true" + :single-embed="isSingleChart" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue index a3c6de14aa4..1bb40447a3e 100644 --- a/app/assets/javascripts/monitoring/components/empty_state.vue +++ b/app/assets/javascripts/monitoring/components/empty_state.vue @@ -1,7 +1,11 @@ <script> import { __ } from '~/locale'; +import { GlEmptyState } from '@gitlab/ui'; export default { + components: { + GlEmptyState, + }, props: { documentationPath: { type: String, @@ -37,6 +41,11 @@ export default { type: String, required: true, }, + compact: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -58,6 +67,8 @@ export default { If this takes a long time, ensure that data is available.`), buttonText: __('View documentation'), buttonPath: this.documentationPath, + secondaryButtonText: '', + secondaryButtonPath: '', }, noData: { svgUrl: this.emptyNoDataSvgPath, @@ -66,13 +77,19 @@ export default { no data to display.`), buttonText: __('Configure Prometheus'), buttonPath: this.settingsPath, + secondaryButtonText: '', + secondaryButtonPath: '', }, unableToConnect: { svgUrl: this.emptyUnableToConnectSvgPath, title: __('Unable to connect to Prometheus server'), - description: 'Ensure connectivity is available from the GitLab server to the ', + description: __( + 'Ensure connectivity is available from the GitLab server to the Prometheus server', + ), buttonText: __('View documentation'), buttonPath: this.documentationPath, + secondaryButtonText: __('Configure Prometheus'), + secondaryButtonPath: this.settingsPath, }, }, }; @@ -81,45 +98,19 @@ export default { currentState() { return this.states[this.selectedState]; }, - showButtonDescription() { - if (this.selectedState === 'unableToConnect') return true; - return false; - }, }, }; </script> <template> - <div class="row empty-state js-empty-state"> - <div class="col-12"> - <div class="state-svg svg-content"> - <img :src="currentState.svgUrl" /> - </div> - </div> - - <div class="col-12"> - <div class="text-content"> - <h4 class="state-title text-center">{{ currentState.title }}</h4> - <p class="state-description"> - {{ currentState.description }} - <a v-if="showButtonDescription" :href="settingsPath">{{ __('Prometheus server') }}</a> - </p> - - <div class="text-center"> - <a - v-if="currentState.buttonPath" - :href="currentState.buttonPath" - class="btn btn-success" - >{{ currentState.buttonText }}</a - > - <a - v-if="currentState.secondaryButtonPath" - :href="currentState.secondaryButtonPath" - class="btn" - >{{ currentState.secondaryButtonText }}</a - > - </div> - </div> - </div> - </div> + <gl-empty-state + :title="currentState.title" + :description="currentState.description" + :primary-button-text="currentState.buttonText" + :primary-button-link="currentState.buttonPath" + :secondary-button-text="currentState.secondaryButtonText" + :secondary-button-link="currentState.secondaryButtonPath" + :svg-path="currentState.svgUrl" + :compact="compact" + /> </template> diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue index b20ad1802f3..0f5c5b3d60f 100644 --- a/app/assets/javascripts/monitoring/components/graph_group.vue +++ b/app/assets/javascripts/monitoring/components/graph_group.vue @@ -10,6 +10,10 @@ export default { required: false, default: true, }, + collapseGroup: { + type: Boolean, + required: true, + }, }, }; </script> @@ -19,7 +23,7 @@ export default { <div class="card-header"> <h4>{{ name }}</h4> </div> - <div class="card-body prometheus-graph-group"><slot></slot></div> + <div v-if="collapseGroup" class="card-body prometheus-graph-group"><slot></slot></div> </div> <div v-else class="prometheus-graph-group"><slot></slot></div> </template> diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue new file mode 100644 index 00000000000..73ff651d510 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/panel_type.vue @@ -0,0 +1,141 @@ +<script> +import { mapState } from 'vuex'; +import _ from 'underscore'; +import { __ } from '~/locale'; +import { + GlDropdown, + GlDropdownItem, + GlModal, + GlModalDirective, + GlTooltipDirective, +} from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import MonitorTimeSeriesChart from './charts/time_series.vue'; +import MonitorSingleStatChart from './charts/single_stat.vue'; +import MonitorEmptyChart from './charts/empty_chart.vue'; + +export default { + components: { + MonitorSingleStatChart, + MonitorTimeSeriesChart, + MonitorEmptyChart, + Icon, + GlDropdown, + GlDropdownItem, + GlModal, + }, + directives: { + GlModal: GlModalDirective, + GlTooltip: GlTooltipDirective, + }, + props: { + clipboardText: { + type: String, + required: true, + }, + graphData: { + type: Object, + required: true, + }, + dashboardWidth: { + type: Number, + required: true, + }, + index: { + type: String, + required: false, + default: '', + }, + }, + computed: { + ...mapState('monitoringDashboard', ['deploymentData', 'projectPath']), + alertWidgetAvailable() { + return IS_EE && this.prometheusAlertsAvailable && this.alertsEndpoint && this.graphData; + }, + graphDataHasMetrics() { + return this.graphData.queries[0].result.length > 0; + }, + csvText() { + const chartData = this.graphData.queries[0].result[0].values; + const yLabel = this.graphData.y_label; + const header = `timestamp,${yLabel}\r\n`; // eslint-disable-line @gitlab/i18n/no-non-i18n-strings + return chartData.reduce((csv, data) => { + const row = data.join(','); + return `${csv}${row}\r\n`; + }, header); + }, + downloadCsv() { + const data = new Blob([this.csvText], { type: 'text/plain' }); + return window.URL.createObjectURL(data); + }, + }, + methods: { + getGraphAlerts(queries) { + if (!this.allAlerts) return {}; + const metricIdsForChart = queries.map(q => q.metricId); + return _.pick(this.allAlerts, alert => metricIdsForChart.includes(alert.metricId)); + }, + getGraphAlertValues(queries) { + return Object.values(this.getGraphAlerts(queries)); + }, + isPanelType(type) { + return this.graphData.type && this.graphData.type === type; + }, + showToast() { + this.$toast.show(__('Link copied to clipboard')); + }, + }, +}; +</script> +<template> + <monitor-single-stat-chart + v-if="isPanelType('single-stat') && graphDataHasMetrics" + :graph-data="graphData" + /> + <monitor-time-series-chart + v-else-if="graphDataHasMetrics" + :graph-data="graphData" + :deployment-data="deploymentData" + :project-path="projectPath" + :thresholds="getGraphAlertValues(graphData.queries)" + :container-width="dashboardWidth" + group-id="monitor-area-chart" + > + <div class="d-flex align-items-center"> + <alert-widget + v-if="alertWidgetAvailable && graphData" + :modal-id="`alert-modal-${index}`" + :alerts-endpoint="alertsEndpoint" + :relevant-queries="graphData.queries" + :alerts-to-manage="getGraphAlerts(graphData.queries)" + @setAlerts="setAlerts" + /> + <gl-dropdown + v-gl-tooltip + class="mx-2" + toggle-class="btn btn-transparent border-0" + :right="true" + :no-caret="true" + :title="__('More actions')" + > + <template slot="button-content"> + <icon name="ellipsis_v" class="text-secondary" /> + </template> + <gl-dropdown-item :href="downloadCsv" download="chart_metrics.csv"> + {{ __('Download CSV') }} + </gl-dropdown-item> + <gl-dropdown-item + class="js-chart-link" + :data-clipboard-text="clipboardText" + @click="showToast" + > + {{ __('Generate link to chart') }} + </gl-dropdown-item> + <gl-dropdown-item v-if="alertWidgetAvailable" v-gl-modal="`alert-modal-${index}`"> + {{ __('Alerts') }} + </gl-dropdown-item> + </gl-dropdown> + </div> + </monitor-time-series-chart> + <monitor-empty-chart v-else :graph-title="graphData.title" /> +</template> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index 26f1bf3f68d..13aba3d9f44 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -1,11 +1,17 @@ import { __ } from '~/locale'; +export const sidebarAnimationDuration = 300; // milliseconds. + export const chartHeight = 300; export const graphTypes = { deploymentData: 'scatter', }; +export const symbolSizes = { + default: 14, +}; + export const lineTypes = { default: 'solid', }; @@ -19,11 +25,24 @@ export const timeWindows = { oneWeek: __('1 week'), }; -export const timeWindowsKeyNames = { - thirtyMinutes: 'thirtyMinutes', - threeHours: 'threeHours', - eightHours: 'eightHours', - oneDay: 'oneDay', - threeDays: 'threeDays', - oneWeek: 'oneWeek', +export const dateFormats = { + timeOfDay: 'h:MM TT', + default: 'dd mmm yyyy, h:MMTT', }; + +export const secondsIn = { + thirtyMinutes: 60 * 30, + threeHours: 60 * 60 * 3, + eightHours: 60 * 60 * 8, + oneDay: 60 * 60 * 24 * 1, + threeDays: 60 * 60 * 24 * 3, + oneWeek: 60 * 60 * 24 * 7 * 1, +}; + +export const timeWindowsKeyNames = Object.keys(secondsIn).reduce( + (otherTimeWindows, timeWindow) => ({ + ...otherTimeWindows, + [timeWindow]: timeWindow, + }), + {}, +); diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index 97d149e9ad5..51cef20455c 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -1,9 +1,12 @@ import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; import { parseBoolean } from '~/lib/utils/common_utils'; import { getParameterValues } from '~/lib/utils/url_utility'; import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue'; import store from './stores'; +Vue.use(GlToast); + export default (props = {}) => { const el = document.getElementById('prometheus-graphs'); @@ -12,6 +15,7 @@ export default (props = {}) => { store.dispatch('monitoringDashboard/setFeatureFlags', { prometheusEndpointEnabled: gon.features.environmentMetricsUsePrometheusEndpoint, multipleDashboardsEnabled: gon.features.environmentMetricsShowMultipleDashboards, + additionalPanelTypesEnabled: gon.features.environmentMetricsAdditionalPanelTypes, }); } diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 0fa2a5d6370..0cbad179f17 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -37,10 +37,15 @@ export const setEndpoints = ({ commit }, endpoints) => { export const setFeatureFlags = ( { commit }, - { prometheusEndpointEnabled, multipleDashboardsEnabled }, + { prometheusEndpointEnabled, multipleDashboardsEnabled, additionalPanelTypesEnabled }, ) => { commit(types.SET_DASHBOARD_ENABLED, prometheusEndpointEnabled); commit(types.SET_MULTIPLE_DASHBOARDS_ENABLED, multipleDashboardsEnabled); + commit(types.SET_ADDITIONAL_PANEL_TYPES_ENABLED, additionalPanelTypesEnabled); +}; + +export const setShowErrorBanner = ({ commit }, enabled) => { + commit(types.SET_SHOW_ERROR_BANNER, enabled); }; export const requestMetricsDashboard = ({ commit }) => { @@ -98,7 +103,9 @@ export const fetchMetricsData = ({ state, dispatch }, params) => { }) .catch(error => { dispatch('receiveMetricsDataFailure', error); - createFlash(s__('Metrics|There was an error while retrieving metrics')); + if (state.setShowErrorBanner) { + createFlash(s__('Metrics|There was an error while retrieving metrics')); + } }); }; @@ -118,7 +125,9 @@ export const fetchDashboard = ({ state, dispatch }, params) => { }) .catch(error => { dispatch('receiveMetricsDashboardFailure', error); - createFlash(s__('Metrics|There was an error while retrieving metrics')); + if (state.setShowErrorBanner) { + createFlash(s__('Metrics|There was an error while retrieving metrics')); + } }); }; @@ -142,7 +151,7 @@ function fetchPrometheusResult(prometheusEndpoint, params) { */ export const fetchPrometheusMetric = ({ commit }, { metric, params }) => { const { start, end } = params; - const timeDiff = end - start; + const timeDiff = (new Date(end) - new Date(start)) / 1000; const minStep = 60; const queryDataPoints = 600; diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index 2c78a0b9315..4b1aadbcf05 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -11,7 +11,9 @@ export const SET_QUERY_RESULT = 'SET_QUERY_RESULT'; export const SET_TIME_WINDOW = 'SET_TIME_WINDOW'; export const SET_DASHBOARD_ENABLED = 'SET_DASHBOARD_ENABLED'; export const SET_MULTIPLE_DASHBOARDS_ENABLED = 'SET_MULTIPLE_DASHBOARDS_ENABLED'; +export const SET_ADDITIONAL_PANEL_TYPES_ENABLED = 'SET_ADDITIONAL_PANEL_TYPES_ENABLED'; export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS'; export const SET_ENDPOINTS = 'SET_ENDPOINTS'; export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE'; export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE'; +export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index a85a7723c1f..b19520d6638 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -75,6 +75,7 @@ export default { state.deploymentsEndpoint = endpoints.deploymentsEndpoint; state.dashboardEndpoint = endpoints.dashboardEndpoint; state.currentDashboard = endpoints.currentDashboard; + state.projectPath = endpoints.projectPath; }, [types.SET_DASHBOARD_ENABLED](state, enabled) { state.useDashboardEndpoint = enabled; @@ -92,4 +93,10 @@ export default { [types.SET_ALL_DASHBOARDS](state, dashboards) { state.allDashboards = dashboards; }, + [types.SET_ADDITIONAL_PANEL_TYPES_ENABLED](state, enabled) { + state.additionalPanelTypesEnabled = enabled; + }, + [types.SET_SHOW_ERROR_BANNER](state, enabled) { + state.showErrorBanner = enabled; + }, }; diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index de711d6ccae..440bdc951e0 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -9,12 +9,15 @@ export default () => ({ dashboardEndpoint: invalidUrl, useDashboardEndpoint: false, multipleDashboardsEnabled: false, + additionalPanelTypesEnabled: false, emptyState: 'gettingStarted', showEmptyState: true, + showErrorBanner: true, groups: [], deploymentData: [], environments: [], metricsWithData: [], allDashboards: [], currentDashboard: null, + projectPath: null, }); diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index 84e1f1c4c20..938ee2f0a9a 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -36,15 +36,26 @@ function removeTimeSeriesNoData(queries) { // { metricId: 2, ...query2Attrs }] }, // { title: 'new title', y_label: 'MB', queries: [{ metricId: 3, ...query3Attrs }]} // ] -function groupQueriesByChartInfo(metrics) { +export function groupQueriesByChartInfo(metrics) { const metricsByChart = metrics.reduce((accumulator, metric) => { const { queries, ...chart } = metric; - const metricId = chart.id ? chart.id.toString() : null; const chartKey = `${chart.title}|${chart.y_label}`; accumulator[chartKey] = accumulator[chartKey] || { ...chart, queries: [] }; - queries.forEach(queryAttrs => accumulator[chartKey].queries.push({ metricId, ...queryAttrs })); + queries.forEach(queryAttrs => { + let metricId; + + if (chart.id) { + metricId = chart.id.toString(); + } else if (queryAttrs.metric_id) { + metricId = queryAttrs.metric_id.toString(); + } else { + metricId = null; + } + + accumulator[chartKey].queries.push({ metricId, ...queryAttrs }); + }); return accumulator; }, {}); @@ -58,13 +69,26 @@ export const sortMetrics = metrics => .sortBy('weight') .value(); -export const normalizeQueryResult = timeSeries => ({ - ...timeSeries, - values: timeSeries.values.map(([timestamp, value]) => [ - new Date(timestamp * 1000).toISOString(), - Number(value), - ]), -}); +export const normalizeQueryResult = timeSeries => { + let normalizedResult = {}; + + if (timeSeries.values) { + normalizedResult = { + ...timeSeries, + values: timeSeries.values.map(([timestamp, value]) => [ + new Date(timestamp * 1000).toISOString(), + Number(value), + ]), + }; + } else if (timeSeries.value) { + normalizedResult = { + ...timeSeries, + value: [new Date(timeSeries.value[0] * 1000).toISOString(), Number(timeSeries.value[1])], + }; + } + + return normalizedResult; +}; export const normalizeMetrics = metrics => { const groupedMetrics = groupQueriesByChartInfo(metrics); diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index ef309c8a398..46b01f753f8 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -1,33 +1,46 @@ -import { timeWindows } from './constants'; +import { secondsIn, timeWindowsKeyNames } from './constants'; -/** - * method that converts a predetermined time window to minutes - * defaults to 8 hours as the default option - * @param {String} timeWindow - The time window to convert to minutes - * @returns {number} The time window in minutes - */ -const getTimeDifferenceSeconds = timeWindow => { - switch (timeWindow) { - case timeWindows.thirtyMinutes: - return 60 * 30; - case timeWindows.threeHours: - return 60 * 60 * 3; - case timeWindows.oneDay: - return 60 * 60 * 24 * 1; - case timeWindows.threeDays: - return 60 * 60 * 24 * 3; - case timeWindows.oneWeek: - return 60 * 60 * 24 * 7 * 1; - default: - return 60 * 60 * 8; - } +export const getTimeDiff = timeWindow => { + const end = Math.floor(Date.now() / 1000); // convert milliseconds to seconds + const difference = secondsIn[timeWindow] || secondsIn.eightHours; + const start = end - difference; + + return { + start: new Date(start * 1000).toISOString(), + end: new Date(end * 1000).toISOString(), + }; }; -export const getTimeDiff = selectedTimeWindow => { - const end = Date.now() / 1000; // convert milliseconds to seconds - const start = end - getTimeDifferenceSeconds(selectedTimeWindow); +export const getTimeWindow = ({ start, end }) => + Object.entries(secondsIn).reduce((acc, [timeRange, value]) => { + if (end - start === value) { + return timeRange; + } + return acc; + }, timeWindowsKeyNames.eightHours); + +/** + * This method is used to validate if the graph data format for a chart component + * that needs a time series as a response from a prometheus query (query_range) is + * of a valid format or not. + * @param {Object} graphData the graph data response from a prometheus request + * @returns {boolean} whether the graphData format is correct + */ +export const graphDataValidatorForValues = (isValues, graphData) => { + const responseValueKeyName = isValues ? 'value' : 'values'; - return { start, end }; + return ( + Array.isArray(graphData.queries) && + graphData.queries.filter(query => { + if (Array.isArray(query.result)) { + return ( + query.result.filter(res => Array.isArray(res[responseValueKeyName])).length === + query.result.length + ); + } + return false; + }).length === graphData.queries.length + ); }; export default {}; diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js index 842a209a545..622db360d1f 100644 --- a/app/assets/javascripts/mr_notes/init_notes.js +++ b/app/assets/javascripts/mr_notes/init_notes.js @@ -3,6 +3,7 @@ import Vue from 'vue'; import { mapActions, mapState, mapGetters } from 'vuex'; import store from 'ee_else_ce/mr_notes/stores'; import notesApp from '../notes/components/notes_app.vue'; +import discussionKeyboardNavigator from '../notes/components/discussion_keyboard_navigator.vue'; export default () => { // eslint-disable-next-line no-new @@ -56,15 +57,23 @@ export default () => { }, }, render(createElement) { - return createElement('notes-app', { - props: { - noteableData: this.noteableData, - notesData: this.notesData, - userData: this.currentUserData, - shouldShow: this.activeTab === 'show', - helpPagePath: this.helpPagePath, - }, - }); + const isDiffView = this.activeTab === 'diffs'; + + // NOTE: Even though `discussionKeyboardNavigator` is added to the `notes-app`, + // it adds a global key listener so it works on the diffs tab as well. + // If we create a single Vue app for all of the MR tabs, we should move this + // up the tree, to the root. + return createElement(discussionKeyboardNavigator, { props: { isDiffView } }, [ + createElement('notes-app', { + props: { + noteableData: this.noteableData, + notesData: this.notesData, + userData: this.currentUserData, + shouldShow: this.activeTab === 'show', + helpPagePath: this.helpPagePath, + }, + }), + ]); }, }); }; diff --git a/app/assets/javascripts/mr_notes/stores/index.js b/app/assets/javascripts/mr_notes/stores/index.js index c4225c8ec08..8fbd0291a7d 100644 --- a/app/assets/javascripts/mr_notes/stores/index.js +++ b/app/assets/javascripts/mr_notes/stores/index.js @@ -9,7 +9,7 @@ Vue.use(Vuex); export const createStore = () => new Vuex.Store({ modules: { - page: mrPageModule, + page: mrPageModule(), notes: notesModule(), diffs: diffsModule(), }, diff --git a/app/assets/javascripts/mr_notes/stores/modules/index.js b/app/assets/javascripts/mr_notes/stores/modules/index.js index 660081f76c8..c28e666943b 100644 --- a/app/assets/javascripts/mr_notes/stores/modules/index.js +++ b/app/assets/javascripts/mr_notes/stores/modules/index.js @@ -2,11 +2,11 @@ import actions from '../actions'; import getters from '../getters'; import mutations from '../mutations'; -export default { +export default () => ({ state: { activeTab: null, }, actions, getters, mutations, -}; +}); diff --git a/app/assets/javascripts/mr_popover/components/mr_popover.vue b/app/assets/javascripts/mr_popover/components/mr_popover.vue index c203cb0667c..b81600660f6 100644 --- a/app/assets/javascripts/mr_popover/components/mr_popover.vue +++ b/app/assets/javascripts/mr_popover/components/mr_popover.vue @@ -1,4 +1,5 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { GlPopover, GlSkeletonLoading } from '@gitlab/ui'; import Icon from '../../vue_shared/components/icon.vue'; import CiIcon from '../../vue_shared/components/ci_icon.vue'; @@ -7,7 +8,8 @@ import query from '../queries/merge_request.query.graphql'; import { mrStates, humanMRStates } from '../constants'; export default { - name: 'MRPopover', + // name: 'MRPopover' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25 + name: 'MRPopover', // eslint-disable-line @gitlab/i18n/no-non-i18n-strings components: { GlPopover, GlSkeletonLoading, @@ -102,9 +104,11 @@ export default { <ci-icon v-if="detailedStatus" :status="detailedStatus" /> </div> <h5 class="my-2">{{ mergeRequestTitle }}</h5> + <!-- eslint-disable @gitlab/vue-i18n/no-bare-strings --> <div class="text-secondary"> {{ `${projectPath}!${mergeRequestIID}` }} </div> + <!-- eslint-enable @gitlab/vue-i18n/no-bare-strings --> </div> </gl-popover> </template> diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue index 61eabbcb8b2..9e4a92426ee 100644 --- a/app/assets/javascripts/notebook/cells/markdown.vue +++ b/app/assets/javascripts/notebook/cells/markdown.vue @@ -43,7 +43,7 @@ renderer.paragraph = t => { if (typeof katex !== 'undefined') { const katexString = text .replace(/&/g, '&') - .replace(/&=&/g, '\\space=\\space') + .replace(/&=&/g, '\\space=\\space') // eslint-disable-line @gitlab/i18n/no-non-i18n-strings .replace(/<(\/?)em>/g, '_'); const regex = new RegExp(katexRegexString, 'gi'); const matchLocation = katexString.search(regex); diff --git a/app/assets/javascripts/notebook/cells/output/image.vue b/app/assets/javascripts/notebook/cells/output/image.vue index f1130275525..842d9e8da0d 100644 --- a/app/assets/javascripts/notebook/cells/output/image.vue +++ b/app/assets/javascripts/notebook/cells/output/image.vue @@ -25,7 +25,7 @@ export default { }, computed: { imgSrc() { - return `data:${this.outputType};base64,${this.rawCode}`; + return `data:${this.outputType};base64,${this.rawCode}`; // eslint-disable-line @gitlab/i18n/no-non-i18n-strings }, showOutput() { return this.index === 0; diff --git a/app/assets/javascripts/notebook/index.vue b/app/assets/javascripts/notebook/index.vue index e7056c03e4a..4a3c1a28279 100644 --- a/app/assets/javascripts/notebook/index.vue +++ b/app/assets/javascripts/notebook/index.vue @@ -39,7 +39,7 @@ export default { }, methods: { cellType(type) { - return `${type}-cell`; + return `${type}-cell`; // eslint-disable-line @gitlab/i18n/no-non-i18n-strings }, }, }; diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 075c28e8d07..fda494fec07 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -13,6 +13,7 @@ import { splitCamelCase, slugifyWithUnderscore, } from '../../lib/utils/text_utility'; +import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import * as constants from '../constants'; import eventHub from '../event_hub'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; @@ -65,14 +66,12 @@ export default { return this.getUserData.id; }, commentButtonTitle() { - return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion'; + return this.noteType === constants.COMMENT ? __('Comment') : __('Start thread'); }, startDiscussionDescription() { - let text = 'Discuss a specific suggestion or question'; - if (this.getNoteableData.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE) { - text += ' that needs to be resolved'; - } - return `${text}.`; + return this.getNoteableData.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE + ? __('Discuss a specific suggestion or question that needs to be resolved.') + : __('Discuss a specific suggestion or question.'); }, isOpen() { return this.openState === constants.OPENED || this.openState === constants.REOPENED; @@ -127,8 +126,8 @@ export default { }, issuableTypeTitle() { return this.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE - ? 'merge request' - : 'issue'; + ? __('merge request') + : __('issue'); }, trackingLabel() { return slugifyWithUnderscore(`${this.commentButtonTitle} button`); @@ -203,7 +202,7 @@ export default { this.discard(); } else { Flash( - 'Something went wrong while adding your comment. Please try again.', + __('Something went wrong while adding your comment. Please try again.'), 'alert', this.$refs.commentForm, ); @@ -219,8 +218,9 @@ export default { .catch(() => { this.enableButton(); this.discard(false); - const msg = `Your comment could not be submitted! -Please check your network connection and try again.`; + const msg = __( + 'Your comment could not be submitted! Please check your network connection and try again.', + ); Flash(msg, 'alert', this.$el); this.note = noteData.data.note.note; // Restore textarea content. this.removePlaceholderNotes(); @@ -235,7 +235,10 @@ Please check your network connection and try again.`; toggleIssueState() { if (this.isOpen) { this.closeIssue() - .then(() => this.enableButton()) + .then(() => { + this.enableButton(); + refreshUserMergeRequestCounts(); + }) .catch(() => { this.enableButton(); this.toggleStateButtonLoading(false); @@ -248,7 +251,10 @@ Please check your network connection and try again.`; }); } else { this.reopenIssue() - .then(() => this.enableButton()) + .then(() => { + this.enableButton(); + refreshUserMergeRequestCounts(); + }) .catch(({ data }) => { this.enableButton(); this.toggleStateButtonLoading(false); @@ -298,7 +304,7 @@ Please check your network connection and try again.`; const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType)); this.autosave = new Autosave($(this.$refs.textarea), [ - 'Note', + __('Note'), noteableType, this.getNoteableData.id, ]); @@ -359,8 +365,8 @@ Please check your network connection and try again.`; class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input" data-supports-quick-actions="true" - aria-label="Description" - placeholder="Write a comment or drag your files here…" + :aria-label="__('Description')" + :placeholder="__('Write a comment or drag your files here…')" @keydown.up="editCurrentUserLastNote()" @keydown.meta.enter="handleSave()" @keydown.ctrl.enter="handleSave()" @@ -381,7 +387,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" data-track-event="click_button" @click.prevent="handleSave()" > - {{ __(commentButtonTitle) }} + {{ commentButtonTitle }} </button> <button :disabled="isSubmitButtonDisabled" @@ -390,7 +396,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" class="btn btn-success note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown" data-display="static" data-toggle="dropdown" - aria-label="Open comment type dropdown" + :aria-label="__('Open comment type dropdown')" > <i aria-hidden="true" class="fa fa-caret-down toggle-icon"> </i> </button> @@ -404,8 +410,14 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" > <i aria-hidden="true" class="fa fa-check icon"> </i> <div class="description"> - <strong>Comment</strong> - <p>Add a general comment to this {{ noteableDisplayName }}.</p> + <strong>{{ __('Comment') }}</strong> + <p> + {{ + sprintf(__('Add a general comment to this %{noteableDisplayName}.'), { + noteableDisplayName, + }) + }} + </p> </div> </button> </li> @@ -418,7 +430,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" > <i aria-hidden="true" class="fa fa-check icon"> </i> <div class="description"> - <strong>Start discussion</strong> + <strong>{{ __('Start thread') }}</strong> <p>{{ startDiscussionDescription }}</p> </div> </button> diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index 54c242b2fda..df537ba1ed2 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -1,4 +1,5 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { mapState, mapActions } from 'vuex'; import DiffFileHeader from '~/diffs/components/diff_file_header.vue'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; @@ -100,7 +101,7 @@ export default { class="btn-link btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button" @click="fetchDiff" > - Try again + {{ __('Try again') }} </button> </td> <td v-else class="line_content js-success-lazy-load"> diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue index 1357a5268d6..edab750b572 100644 --- a/app/assets/javascripts/notes/components/discussion_actions.vue +++ b/app/assets/javascripts/notes/components/discussion_actions.vue @@ -40,21 +40,26 @@ export default { <template> <div class="discussion-with-resolve-btn clearfix"> - <reply-placeholder class="qa-discussion-reply" @onClick="$emit('showReplyForm')" /> + <reply-placeholder + data-qa-selector="discussion_reply_tab" + :button-text="s__('MergeRequests|Reply...')" + @onClick="$emit('showReplyForm')" + /> <div class="btn-group discussion-actions" role="group"> - <resolve-discussion-button - v-if="discussion.resolvable" - :is-resolving="isResolving" - :button-title="resolveButtonTitle" - @onClick="$emit('resolve')" - /> + <div class="btn-group"> + <resolve-discussion-button + v-if="discussion.resolvable" + :is-resolving="isResolving" + :button-title="resolveButtonTitle" + @onClick="$emit('resolve')" + /> + </div> <resolve-with-issue-button v-if="discussion.resolvable && resolveWithIssuePath" :url="resolveWithIssuePath" /> </div> - <div v-if="discussion.resolvable && shouldShowJumpToNextDiscussion" class="btn-group discussion-actions ml-sm-2" diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index efd84f5722c..d7ffa0abb79 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -61,7 +61,7 @@ export default { </span> <span class="line-resolve-text"> {{ resolvedDiscussionsCount }}/{{ resolvableDiscussionsCount }} - {{ n__('discussion resolved', 'discussions resolved', resolvableDiscussionsCount) }} + {{ n__('thread resolved', 'threads resolved', resolvableDiscussionsCount) }} </span> </div> <div @@ -72,7 +72,7 @@ export default { <a v-gl-tooltip :href="resolveAllDiscussionsIssuePath" - :title="s__('Resolve all discussions in new issue')" + :title="s__('Resolve all threads in new issue')" class="new-issue-for-discussion btn btn-default discussion-create-issue-btn" > <icon name="issue-new" /> @@ -81,7 +81,7 @@ export default { <div v-if="isLoggedIn && !allResolved" class="btn-group btn-group-sm" role="group"> <button v-gl-tooltip - title="Jump to first unresolved discussion" + title="Jump to first unresolved thread" class="btn btn-default discussion-next-btn" @click="jumpToFirstUnresolvedDiscussion" > diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue index eb3fbbe1385..743684e7046 100644 --- a/app/assets/javascripts/notes/components/discussion_filter.vue +++ b/app/assets/javascripts/notes/components/discussion_filter.vue @@ -61,7 +61,7 @@ export default { }, methods: { ...mapActions(['filterDiscussion', 'setCommentsDisabled', 'setTargetNoteHash']), - selectFilter(value) { + selectFilter(value, persistFilter = true) { const filter = parseInt(value, 10); // close dropdown @@ -69,7 +69,11 @@ export default { if (filter === this.currentValue) return; this.currentValue = filter; - this.filterDiscussion({ path: this.getNotesDataByProp('discussionsPath'), filter }); + this.filterDiscussion({ + path: this.getNotesDataByProp('discussionsPath'), + filter, + persistFilter, + }); this.toggleCommentsForm(); }, toggleDropdown() { @@ -85,7 +89,7 @@ export default { const hash = getLocationHash(); if (/^note_/.test(hash) && this.currentValue !== DISCUSSION_FILTERS_DEFAULT_VALUE) { - this.selectFilter(this.defaultValue); + this.selectFilter(this.defaultValue, false); this.toggleDropdown(); // close dropdown this.setTargetNoteHash(hash); } diff --git a/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue b/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue new file mode 100644 index 00000000000..7fbfe8eebb2 --- /dev/null +++ b/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue @@ -0,0 +1,51 @@ +<script> +/* global Mousetrap */ +import 'mousetrap'; +import { mapGetters, mapActions } from 'vuex'; +import discussionNavigation from '~/notes/mixins/discussion_navigation'; + +export default { + mixins: [discussionNavigation], + props: { + isDiffView: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + currentDiscussionId: null, + }; + }, + computed: { + ...mapGetters(['nextUnresolvedDiscussionId', 'previousUnresolvedDiscussionId']), + }, + mounted() { + Mousetrap.bind('n', () => this.jumpToNextDiscussion()); + Mousetrap.bind('p', () => this.jumpToPreviousDiscussion()); + }, + beforeDestroy() { + Mousetrap.unbind('n'); + Mousetrap.unbind('p'); + }, + methods: { + ...mapActions(['expandDiscussion']), + jumpToNextDiscussion() { + const nextId = this.nextUnresolvedDiscussionId(this.currentDiscussionId, this.isDiffView); + + this.jumpToDiscussion(nextId); + this.currentDiscussionId = nextId; + }, + jumpToPreviousDiscussion() { + const prevId = this.previousUnresolvedDiscussionId(this.currentDiscussionId, this.isDiffView); + + this.jumpToDiscussion(prevId); + this.currentDiscussionId = prevId; + }, + }, + render() { + return this.$slots.default; + }, +}; +</script> diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue index 30971ad5227..0b136549c14 100644 --- a/app/assets/javascripts/notes/components/discussion_notes.vue +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -1,19 +1,21 @@ <script> -import { mapGetters } from 'vuex'; +import { mapGetters, mapActions } from 'vuex'; import { SYSTEM_NOTE } from '../constants'; import { __ } from '~/locale'; -import NoteableNote from './noteable_note.vue'; -import PlaceholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; -import PlaceholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; +import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; +import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue'; import SystemNote from '~/vue_shared/components/notes/system_note.vue'; +import NoteableNote from './noteable_note.vue'; import ToggleRepliesWidget from './toggle_replies_widget.vue'; import NoteEditedText from './note_edited_text.vue'; +import DiscussionNotesRepliesWrapper from './discussion_notes_replies_wrapper.vue'; export default { name: 'DiscussionNotes', components: { ToggleRepliesWidget, NoteEditedText, + DiscussionNotesRepliesWrapper, }, props: { discussion: { @@ -72,6 +74,7 @@ export default { }, }, methods: { + ...mapActions(['toggleDiscussion']), componentName(note) { if (note.isPlaceholderNote) { if (note.placeholderType === SYSTEM_NOTE) { @@ -101,7 +104,7 @@ export default { <component :is="componentName(firstNote)" :note="componentData(firstNote)" - :line="line" + :line="line || diffLine" :commit="commit" :help-page-path="helpPagePath" :show-reply-button="userCanReply" @@ -118,23 +121,27 @@ export default { /> <slot slot="avatar-badge" name="avatar-badge"></slot> </component> - <toggle-replies-widget - v-if="hasReplies" - :collapsed="!isExpanded" - :replies="replies" - @toggle="$emit('toggleDiscussion')" - /> - <template v-if="isExpanded"> - <component - :is="componentName(note)" - v-for="note in replies" - :key="note.id" - :note="componentData(note)" - :help-page-path="helpPagePath" - :line="line" - @handleDeleteNote="$emit('deleteNote')" + <discussion-notes-replies-wrapper :is-diff-discussion="discussion.diff_discussion"> + <toggle-replies-widget + v-if="hasReplies" + :collapsed="!isExpanded" + :replies="replies" + :class="{ 'discussion-toggle-replies': discussion.diff_discussion }" + @toggle="toggleDiscussion({ discussionId: discussion.id })" /> - </template> + <template v-if="isExpanded"> + <component + :is="componentName(note)" + v-for="note in replies" + :key="note.id" + :note="componentData(note)" + :help-page-path="helpPagePath" + :line="line" + @handleDeleteNote="$emit('deleteNote')" + /> + </template> + <slot :show-replies="isExpanded || !hasReplies" name="footer"></slot> + </discussion-notes-replies-wrapper> </template> <template v-else> <component @@ -148,8 +155,8 @@ export default { > <slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot> </component> + <slot :show-replies="isExpanded || !hasReplies" name="footer"></slot> </template> </ul> - <slot :show-replies="isExpanded || !hasReplies" name="footer"></slot> </div> </template> diff --git a/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue b/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue new file mode 100644 index 00000000000..2ddca56ddd5 --- /dev/null +++ b/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue @@ -0,0 +1,27 @@ +<script> +/** + * Wrapper for discussion notes replies section. + * + * This is a functional component using the render method because in some cases + * the wrapper is not needed and we want to simply render along the children. + */ +export default { + functional: true, + props: { + isDiffDiscussion: { + type: Boolean, + required: false, + default: false, + }, + }, + render(h, { props, children }) { + if (props.isDiffDiscussion) { + return h('li', { class: 'discussion-collapsible bordered-box clearfix' }, [ + h('ul', { class: 'notes' }, children), + ]); + } + + return children; + }, +}; +</script> diff --git a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue index ea590905e3c..0204169214b 100644 --- a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue +++ b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue @@ -1,6 +1,12 @@ <script> export default { name: 'ReplyPlaceholder', + props: { + buttonText: { + type: String, + required: true, + }, + }, }; </script> @@ -12,6 +18,6 @@ export default { :title="s__('MergeRequests|Add a reply')" @click="$emit('onClick')" > - {{ s__('MergeRequests|Reply...') }} + {{ buttonText }} </button> </template> diff --git a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue index e413398696a..f03e6fd73d7 100644 --- a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue +++ b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue @@ -25,7 +25,7 @@ export default { <gl-button v-gl-tooltip :href="url" - :title="s__('MergeRequests|Resolve this discussion in a new issue')" + :title="s__('MergeRequests|Resolve this thread in a new issue')" class="new-issue-for-discussion discussion-create-issue-btn" > <icon name="issue-new" /> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 844d0c3e376..6cc873359da 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -165,7 +165,7 @@ export default { v-gl-tooltip type="button" title="Edit comment" - class="note-action-button js-note-edit btn btn-transparent" + class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button" @click="onEdit" > <icon name="pencil" css-classes="link-highlight" /> diff --git a/app/assets/javascripts/notes/components/note_actions/reply_button.vue b/app/assets/javascripts/notes/components/note_actions/reply_button.vue index be8e42af9ea..1aeb07d6608 100644 --- a/app/assets/javascripts/notes/components/note_actions/reply_button.vue +++ b/app/assets/javascripts/notes/components/note_actions/reply_button.vue @@ -19,7 +19,7 @@ export default { <gl-button ref="button" v-gl-tooltip - class="note-action-button" + class="note-action-button js-note-action-reply" variant="transparent" :title="__('Reply to comment')" @click="$emit('startReplying')" diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index 941b6d5cab3..d4a57d5d58d 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -4,6 +4,7 @@ import tooltip from '~/vue_shared/directives/tooltip'; import Icon from '~/vue_shared/components/icon.vue'; import Flash from '../../flash'; import { glEmojiTag } from '../../emoji'; +import { __, sprintf } from '~/locale'; export default { components: { @@ -108,23 +109,26 @@ export default { // Add myself to the beginning of the list so title will start with You. if (hasReactionByCurrentUser) { - namesToShow.unshift('You'); + namesToShow.unshift(__('You')); } let title = ''; // We have 10+ awarded user, join them with comma and add `and x more`. if (remainingAwardList.length) { - title = `${namesToShow.join(', ')}, and ${remainingAwardList.length} more.`; + title = sprintf(__(`%{listToShow}, and %{awardsListLength} more.`), { + listToShow: namesToShow.join(', '), + awardsListLength: remainingAwardList.length, + }); } else if (namesToShow.length > 1) { // Join all names with comma but not the last one, it will be added with and text. title = namesToShow.slice(0, namesToShow.length - 1).join(', '); // If we have more than 2 users we need an extra comma before and text. title += namesToShow.length > 2 ? ',' : ''; - title += ` and ${namesToShow.slice(-1)}`; // Append and text + title += sprintf(__(` and %{sliced}`), { sliced: namesToShow.slice(-1) }); // Append and text } else { // We have only 2 users so join them with and. - title = namesToShow.join(' and '); + title = namesToShow.join(__(' and ')); } return title; @@ -155,7 +159,7 @@ export default { awardName: parsedName, }; - this.toggleAwardRequest(data).catch(() => Flash('Something went wrong on our end.')); + this.toggleAwardRequest(data).catch(() => Flash(__('Something went wrong on our end.'))); }, }, }; @@ -184,7 +188,7 @@ export default { :class="{ 'js-user-authored': isAuthoredByMe }" class="award-control btn js-add-award" title="Add reaction" - aria-label="Add reaction" + :aria-label="__('Add reaction')" data-boundary="viewport" type="button" > diff --git a/app/assets/javascripts/notes/components/note_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue index 15ce49d7c31..1af5af5c470 100644 --- a/app/assets/javascripts/notes/components/note_edited_text.vue +++ b/app/assets/javascripts/notes/components/note_edited_text.vue @@ -1,4 +1,5 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; export default { diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 042ed196933..222badf70d1 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -1,14 +1,14 @@ <script> import { mergeUrlParams } from '~/lib/utils/url_utility'; import { mapGetters, mapActions } from 'vuex'; +import noteFormMixin from 'ee_else_ce/notes/mixins/note_form'; import eventHub from '../event_hub'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; import markdownField from '../../vue_shared/components/markdown/field.vue'; import issuableStateMixin from '../mixins/issuable_state'; import resolvable from '../mixins/resolvable'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; import { getDraft, updateDraft } from '~/lib/utils/autosave'; -import noteFormMixin from 'ee_else_ce/notes/mixins/note_form'; export default { name: 'NoteForm', @@ -174,6 +174,18 @@ export default { (this.line && this.line.can_receive_suggestion) ); }, + changedCommentText() { + return sprintf( + __( + 'This comment has changed since you started editing, please review the %{startTag}updated comment%{endTag} to ensure information is not lost.', + ), + { + startTag: `<a href="${this.noteHash}" target="_blank" rel="noopener noreferrer">`, + endTag: '</a>', + }, + false, + ); + }, }, watch: { noteBody() { @@ -228,11 +240,11 @@ export default { <template> <div ref="editNoteForm" class="note-edit-form current-note-edit-form js-discussion-note-form"> - <div v-if="conflictWhileEditing" class="js-conflict-edit-warning alert alert-danger"> - This comment has changed since you started editing, please review the - <a :href="noteHash" target="_blank" rel="noopener noreferrer">updated comment</a> to ensure - information is not lost. - </div> + <div + v-if="conflictWhileEditing" + class="js-conflict-edit-warning alert alert-danger" + v-html="changedCommentText" + ></div> <div class="flash-container timeline-content"></div> <form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form"> <issue-warning @@ -264,8 +276,8 @@ export default { name="note[note]" class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form js-vue-textarea qa-reply-input" dir="auto" - aria-label="Description" - placeholder="Write a comment or drag your files here…" + :aria-label="__('Description')" + :placeholder="__('Write a comment or drag your files here…')" @keydown.meta.enter="handleKeySubmit()" @keydown.ctrl.enter="handleKeySubmit()" @keydown.exact.up="editMyLastNote()" @@ -281,13 +293,17 @@ export default { <input v-model="isUnresolving" type="checkbox" - class="qa-unresolve-review-discussion" + data-qa-selector="unresolve_review_discussion_checkbox" /> - {{ __('Unresolve discussion') }} + {{ __('Unresolve thread') }} </template> <template v-else> - <input v-model="isResolving" type="checkbox" class="qa-resolve-review-discussion" /> - {{ __('Resolve discussion') }} + <input + v-model="isResolving" + type="checkbox" + data-qa-selector="resolve_review_discussion_checkbox" + /> + {{ __('Resolve thread') }} </template> </label> </p> @@ -339,7 +355,7 @@ export default { type="button" @click="cancelHandler()" > - Cancel + {{ __('Cancel') }} </button> </template> </div> diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index fbf82fab9e9..3158e086f6c 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -70,7 +70,7 @@ export default { @click="handleToggle" > <i :class="toggleChevronClass" class="fa" aria-hidden="true"></i> - {{ __('Toggle discussion') }} + {{ __('Toggle thread') }} </button> </div> <a @@ -103,7 +103,7 @@ export default { </template> <i class="fa fa-spinner fa-spin editing-spinner" - aria-label="Comment is being updated" + :aria-label="__('Comment is being updated')" aria-hidden="true" ></i> </span> diff --git a/app/assets/javascripts/notes/components/note_signed_out_widget.vue b/app/assets/javascripts/notes/components/note_signed_out_widget.vue index e3eb92956b1..ccfe84ab098 100644 --- a/app/assets/javascripts/notes/components/note_signed_out_widget.vue +++ b/app/assets/javascripts/notes/components/note_signed_out_widget.vue @@ -1,5 +1,6 @@ <script> import { mapGetters } from 'vuex'; +import { __, sprintf } from '~/locale'; export default { computed: { @@ -10,12 +11,24 @@ export default { signInLink() { return this.getNotesDataByProp('newSessionPath'); }, + signedOutText() { + return sprintf( + __( + 'Please %{startTagRegister}register%{endRegisterTag} or %{startTagSignIn}sign in%{endSignInTag} to reply', + ), + { + startTagRegister: `<a href="${this.registerLink}">`, + startTagSignIn: `<a href="${this.signInLink}">`, + endRegisterTag: '</a>', + endSignInTag: '</a>', + }, + false, + ); + }, }, }; </script> <template> - <div class="disabled-comment text-center"> - Please <a :href="registerLink">register</a> or <a :href="signInLink">sign in</a> to reply - </div> + <div class="disabled-comment text-center" v-html="signedOutText"></div> </template> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index b8eaff32cce..ac743d9f4b8 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -132,7 +132,7 @@ export default { return this.discussion.diff_discussion && this.renderDiffFile; }, shouldGroupReplies() { - return !this.shouldRenderDiffs && !this.discussion.diff_discussion; + return !this.shouldRenderDiffs; }, wrapperComponent() { return this.shouldRenderDiffs ? diffWithNote : 'div'; @@ -144,15 +144,6 @@ export default { return {}; }, - componentClassName() { - if (this.shouldRenderDiffs) { - if (!this.lastUpdatedAt && !this.discussion.resolved) { - return 'unresolved'; - } - } - - return ''; - }, isExpanded() { return this.discussion.expanded || this.alwaysExpanded; }, @@ -174,22 +165,20 @@ export default { active: isActive, } = this.discussion; - let text = s__('MergeRequests|started a discussion'); + let text = s__('MergeRequests|started a thread'); if (isForCommit) { - text = s__( - 'MergeRequests|started a discussion on commit %{linkStart}%{commitId}%{linkEnd}', - ); + text = s__('MergeRequests|started a thread on commit %{linkStart}%{commitId}%{linkEnd}'); } else if (isDiffDiscussion && commitId) { text = isActive - ? s__('MergeRequests|started a discussion on commit %{linkStart}%{commitId}%{linkEnd}') + ? s__('MergeRequests|started a thread on commit %{linkStart}%{commitId}%{linkEnd}') : s__( - 'MergeRequests|started a discussion on an outdated change in commit %{linkStart}%{commitId}%{linkEnd}', + 'MergeRequests|started a thread on an outdated change in commit %{linkStart}%{commitId}%{linkEnd}', ); } else if (isDiffDiscussion) { text = isActive - ? s__('MergeRequests|started a discussion on %{linkStart}the diff%{linkEnd}') + ? s__('MergeRequests|started a thread on %{linkStart}the diff%{linkEnd}') : s__( - 'MergeRequests|started a discussion on %{linkStart}an old version of the diff%{linkEnd}', + 'MergeRequests|started a thread on %{linkStart}an old version of the diff%{linkEnd}', ); } @@ -250,6 +239,11 @@ export default { clearDraft(this.autosaveKey); }, saveReply(noteText, form, callback) { + if (!noteText) { + this.cancelReplyForm(); + callback(); + return; + } const postData = { in_reply_to_discussion_id: this.discussion.reply_id, target_type: this.getNoteableData.targetType, @@ -280,8 +274,9 @@ export default { this.removePlaceholderNotes(); this.isReplying = true; this.$nextTick(() => { - const msg = `Your comment could not be submitted! -Please check your network connection and try again.`; + const msg = __( + 'Your comment could not be submitted! Please check your network connection and try again.', + ); Flash(msg, 'alert', this.$el); this.$refs.noteForm.note = noteText; callback(err); @@ -309,11 +304,11 @@ Please check your network connection and try again.`; </script> <template> - <timeline-entry-item class="note note-discussion" :class="componentClassName"> + <timeline-entry-item class="note note-discussion"> <div class="timeline-content"> <div :data-discussion-id="discussion.id" class="discussion js-discussion-container"> <div v-if="shouldRenderDiffs" class="discussion-header note-wrapper"> - <div v-once class="timeline-icon"> + <div v-once class="timeline-icon align-self-start flex-shrink-0"> <user-avatar-link v-if="author" :link-href="author.path" @@ -322,7 +317,7 @@ Please check your network connection and try again.`; :img-size="40" /> </div> - <div class="timeline-content"> + <div class="timeline-content w-100"> <note-header :author="author" :created-at="firstNote.created_at" @@ -363,7 +358,6 @@ Please check your network connection and try again.`; :line="line" :should-group-replies="shouldGroupReplies" @startReplying="showReplyForm" - @toggleDiscussion="toggleDiscussionHandler" @deleteNote="deleteNoteHandler" > <slot slot="avatar-badge" name="avatar-badge"></slot> @@ -376,7 +370,7 @@ Please check your network connection and try again.`; <div v-else-if="showReplies" :class="{ 'is-replying': isReplying }" - class="discussion-reply-holder" + class="discussion-reply-holder clearfix" > <user-avatar-link v-if="!isReplying && userCanReply" diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index aa80e25a3e0..9019f0542b6 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -5,7 +5,7 @@ import { escape } from 'underscore'; import { truncateSha } from '~/lib/utils/text_utility'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import draftMixin from 'ee_else_ce/notes/mixins/draft'; -import { s__, sprintf } from '../../locale'; +import { __, s__, sprintf } from '../../locale'; import Flash from '../../flash'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import noteHeader from './note_header.vue'; @@ -14,6 +14,7 @@ import NoteBody from './note_body.vue'; import eventHub from '../event_hub'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; +import httpStatusCodes from '~/lib/utils/http_status'; export default { name: 'NoteableNote', @@ -122,15 +123,25 @@ export default { }, methods: { - ...mapActions(['deleteNote', 'updateNote', 'toggleResolveNote', 'scrollToNoteIfNeeded']), + ...mapActions([ + 'deleteNote', + 'removeNote', + 'updateNote', + 'toggleResolveNote', + 'scrollToNoteIfNeeded', + ]), editHandler() { this.isEditing = true; this.$emit('handleEdit'); }, deleteHandler() { - const typeOfComment = this.note.isDraft ? 'pending comment' : 'comment'; - // eslint-disable-next-line no-alert - if (window.confirm(`Are you sure you want to delete this ${typeOfComment}?`)) { + const typeOfComment = this.note.isDraft ? __('pending comment') : __('comment'); + if ( + // eslint-disable-next-line no-alert + window.confirm( + sprintf(__('Are you sure you want to delete this %{typeOfComment}?'), { typeOfComment }), + ) + ) { this.isDeleting = true; this.$emit('handleDeleteNote', this.note); @@ -141,7 +152,7 @@ export default { this.isDeleting = false; }) .catch(() => { - Flash('Something went wrong while deleting your note. Please try again.'); + Flash(__('Something went wrong while deleting your note. Please try again.')); this.isDeleting = false; }); } @@ -181,21 +192,27 @@ export default { this.updateSuccess(); callback(); }) - .catch(() => { - this.isRequesting = false; - this.isEditing = true; - this.$nextTick(() => { - const msg = 'Something went wrong while editing your comment. Please try again.'; - Flash(msg, 'alert', this.$el); - this.recoverNoteContent(noteText); + .catch(response => { + if (response.status === httpStatusCodes.GONE) { + this.removeNote(this.note); + this.updateSuccess(); callback(); - }); + } else { + this.isRequesting = false; + this.isEditing = true; + this.$nextTick(() => { + const msg = __('Something went wrong while editing your comment. Please try again.'); + Flash(msg, 'alert', this.$el); + this.recoverNoteContent(noteText); + callback(); + }); + } }); }, formCancelHandler(shouldConfirm, isDirty) { if (shouldConfirm && isDirty) { // eslint-disable-next-line no-alert - if (!window.confirm('Are you sure you want to cancel editing this comment?')) return; + if (!window.confirm(__('Are you sure you want to cancel editing this comment?'))) return; } this.$refs.noteBody.resetAutoSave(); if (this.oldContent) { diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 4d00e957973..a0695f9e191 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -1,4 +1,5 @@ <script> +import { __ } from '~/locale'; import { mapGetters, mapActions } from 'vuex'; import { getLocationHash } from '../../lib/utils/url_utility'; import Flash from '../../flash'; @@ -170,7 +171,7 @@ export default { .catch(() => { this.setLoadingState(false); this.setNotesFetchedState(true); - Flash('Something went wrong while fetching comments. Please try again.'); + Flash(__('Something went wrong while fetching comments. Please try again.')); }); }, initPolling() { diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 57dd1c5cab2..c70c0e4095c 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -1,5 +1,4 @@ import Vue from 'vue'; -import { isEE } from '~/lib/utils/common_utils'; import initNoteStats from 'ee_else_ce/event_tracking/notes'; import notesApp from './components/notes_app.vue'; import initDiscussionFilters from './discussion_filters'; @@ -41,9 +40,7 @@ document.addEventListener('DOMContentLoaded', () => { }; }, mounted() { - if (isEE) { - initNoteStats(); - } + initNoteStats(); }, render(createElement) { return createElement('notes-app', { diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js index 2329727bca2..16b7598ee09 100644 --- a/app/assets/javascripts/notes/mixins/resolvable.js +++ b/app/assets/javascripts/notes/mixins/resolvable.js @@ -20,13 +20,13 @@ export default { resolveButtonTitle() { if (this.updatedNoteBody) { if (this.discussionResolved) { - return __('Comment & unresolve discussion'); + return __('Comment & unresolve thread'); } - return __('Comment & resolve discussion'); + return __('Comment & resolve thread'); } - return this.discussionResolved ? __('Unresolve discussion') : __('Resolve discussion'); + return this.discussionResolved ? __('Unresolve thread') : __('Resolve thread'); }, }, methods: { diff --git a/app/assets/javascripts/notes/services/notes_service.js b/app/assets/javascripts/notes/services/notes_service.js index 237e70c0a4c..3d239d8cb6b 100644 --- a/app/assets/javascripts/notes/services/notes_service.js +++ b/app/assets/javascripts/notes/services/notes_service.js @@ -1,18 +1,17 @@ import Vue from 'vue'; -import Api from '~/api'; import VueResource from 'vue-resource'; import * as constants from '../constants'; Vue.use(VueResource); export default { - fetchDiscussions(endpoint, filter) { - const config = filter !== undefined ? { params: { notes_filter: filter } } : null; + fetchDiscussions(endpoint, filter, persistFilter = true) { + const config = + filter !== undefined + ? { params: { notes_filter: filter, persist_filter: persistFilter } } + : null; return Vue.http.get(endpoint, config); }, - deleteNote(endpoint) { - return Vue.http.delete(endpoint); - }, replyToDiscussion(endpoint, data) { return Vue.http.post(endpoint, data, { emulateJSON: true }); }, @@ -39,13 +38,7 @@ export default { return Vue.http.get(endpoint, options); }, - toggleAward(endpoint, data) { - return Vue.http.post(endpoint, data, { emulateJSON: true }); - }, 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 63658d49a05..411bd585672 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -14,6 +14,7 @@ import sidebarTimeTrackingEventHub from '../../sidebar/event_hub'; import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils'; import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub'; import { __ } from '~/locale'; +import Api from '~/api'; let eTagPoll; @@ -45,13 +46,13 @@ export const setNotesFetchedState = ({ commit }, state) => export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data); -export const fetchDiscussions = ({ commit, dispatch }, { path, filter }) => +export const fetchDiscussions = ({ commit, dispatch }, { path, filter, persistFilter }) => service - .fetchDiscussions(path, filter) + .fetchDiscussions(path, filter, persistFilter) .then(res => res.json()) .then(discussions => { commit(types.SET_INITIAL_DISCUSSIONS, discussions); - dispatch('updateResolvableDiscussonsCounts'); + dispatch('updateResolvableDiscussionsCounts'); }); export const updateDiscussion = ({ commit, state }, discussion) => { @@ -60,18 +61,22 @@ export const updateDiscussion = ({ commit, state }, discussion) => { return utils.findNoteObjectById(state.discussions, discussion.id); }; -export const deleteNote = ({ commit, dispatch, state }, note) => - service.deleteNote(note.path).then(() => { - const discussion = state.discussions.find(({ id }) => id === note.discussion_id); +export const removeNote = ({ commit, dispatch, state }, note) => { + const discussion = state.discussions.find(({ id }) => id === note.discussion_id); - commit(types.DELETE_NOTE, note); + commit(types.DELETE_NOTE, note); - dispatch('updateMergeRequestWidget'); - dispatch('updateResolvableDiscussonsCounts'); + dispatch('updateMergeRequestWidget'); + dispatch('updateResolvableDiscussionsCounts'); - if (isInMRPage()) { - dispatch('diffs/removeDiscussionsFromDiff', discussion); - } + if (isInMRPage()) { + dispatch('diffs/removeDiscussionsFromDiff', discussion); + } +}; + +export const deleteNote = ({ dispatch }, note) => + axios.delete(note.path).then(() => { + dispatch('removeNote', note); }); export const updateNote = ({ commit, dispatch }, { endpoint, note }) => @@ -117,7 +122,7 @@ export const replyToDiscussion = ({ commit, state, getters, dispatch }, { endpoi dispatch('updateMergeRequestWidget'); dispatch('startTaskList'); - dispatch('updateResolvableDiscussonsCounts'); + dispatch('updateResolvableDiscussionsCounts'); } else { commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res); } @@ -135,7 +140,7 @@ export const createNewNote = ({ commit, dispatch }, { endpoint, data }) => dispatch('updateMergeRequestWidget'); dispatch('startTaskList'); - dispatch('updateResolvableDiscussonsCounts'); + dispatch('updateResolvableDiscussionsCounts'); } return res; }); @@ -168,7 +173,7 @@ export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved, commit(mutationType, res); - dispatch('updateResolvableDiscussonsCounts'); + dispatch('updateResolvableDiscussionsCounts'); dispatch('updateMergeRequestWidget'); }); @@ -356,11 +361,11 @@ export const poll = ({ commit, state, getters, dispatch }) => { }; export const stopPolling = () => { - eTagPoll.stop(); + if (eTagPoll) eTagPoll.stop(); }; export const restartPolling = () => { - eTagPoll.restart(); + if (eTagPoll) eTagPoll.restart(); }; export const fetchData = ({ commit, state, getters }) => { @@ -383,12 +388,9 @@ export const toggleAward = ({ commit, getters }, { awardName, noteId }) => { export const toggleAwardRequest = ({ dispatch }, data) => { const { endpoint, awardName } = data; - return service - .toggleAward(endpoint, { name: awardName }) - .then(res => res.json()) - .then(() => { - dispatch('toggleAward', data); - }); + return axios.post(endpoint, { name: awardName }).then(() => { + dispatch('toggleAward', data); + }); }; export const scrollToNoteIfNeeded = (context, el) => { @@ -413,9 +415,9 @@ export const setLoadingState = ({ commit }, data) => { commit(types.SET_NOTES_LOADING_STATE, data); }; -export const filterDiscussion = ({ dispatch }, { path, filter }) => { +export const filterDiscussion = ({ dispatch }, { path, filter, persistFilter }) => { dispatch('setLoadingState', true); - dispatch('fetchDiscussions', { path, filter }) + dispatch('fetchDiscussions', { path, filter, persistFilter }) .then(() => { dispatch('setLoadingState', false); dispatch('setNotesFetchedState', true); @@ -442,15 +444,14 @@ export const startTaskList = ({ dispatch }) => }), ); -export const updateResolvableDiscussonsCounts = ({ commit }) => +export const updateResolvableDiscussionsCounts = ({ commit }) => commit(types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS); export const submitSuggestion = ( { commit, dispatch }, { discussionId, noteId, suggestionId, flashContainer }, ) => - service - .applySuggestion(suggestionId) + Api.applySuggestion(suggestionId) .then(() => commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId })) .then(() => dispatch('resolveDiscussion', { discussionId }).catch(() => {})) .catch(err => { diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 8aa8f5037b3..3d0ec8cd3a7 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -171,17 +171,33 @@ export const isLastUnresolvedDiscussion = (state, getters) => (discussionId, dif return lastDiscussionId === discussionId; }; +export const findUnresolvedDiscussionIdNeighbor = (state, getters) => ({ + discussionId, + diffOrder, + step, +}) => { + const ids = getters.unresolvedDiscussionsIdsOrdered(diffOrder); + const index = ids.indexOf(discussionId) + step; + + if (index < 0 && step < 0) { + return ids[ids.length - 1]; + } + + if (index === ids.length && step > 0) { + return ids[0]; + } + + return ids[index]; +}; + // Gets the ID of the discussion following the one provided, respecting order (diff or date) // @param {Boolean} discussionId - id of the current discussion // @param {Boolean} diffOrder - is ordered by diff? -export const nextUnresolvedDiscussionId = (state, getters) => (discussionId, diffOrder) => { - const idsOrdered = getters.unresolvedDiscussionsIdsOrdered(diffOrder); - const currentIndex = idsOrdered.indexOf(discussionId); - const slicedIds = idsOrdered.slice(currentIndex + 1, currentIndex + 2); +export const nextUnresolvedDiscussionId = (state, getters) => (discussionId, diffOrder) => + getters.findUnresolvedDiscussionIdNeighbor({ discussionId, diffOrder, step: 1 }); - // Get the first ID if there is none after the currentIndex - return slicedIds.length ? idsOrdered.slice(currentIndex + 1, currentIndex + 2)[0] : idsOrdered[0]; -}; +export const previousUnresolvedDiscussionId = (state, getters) => (discussionId, diffOrder) => + getters.findUnresolvedDiscussionIdNeighbor({ discussionId, diffOrder, step: -1 }); // @param {Boolean} diffOrder - is ordered by diff? export const firstUnresolvedDiscussionId = (state, getters) => diffOrder => { diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js index ed4cef4a917..97dcd54fe88 100644 --- a/app/assets/javascripts/notes/stores/utils.js +++ b/app/assets/javascripts/notes/stores/utils.js @@ -21,7 +21,7 @@ export const getQuickActionText = note => { text = __('Applying multiple commands'); } else { const commandDescription = executedCommands[0].description.toLowerCase(); - text = sprintf(__('Applying command to %{commandDescription}', { commandDescription })); + text = sprintf(__('Applying command to %{commandDescription}'), { commandDescription }); } } diff --git a/app/assets/javascripts/operation_settings/components/external_dashboard.vue b/app/assets/javascripts/operation_settings/components/external_dashboard.vue index ed518611d0b..e90e27a402a 100644 --- a/app/assets/javascripts/operation_settings/components/external_dashboard.vue +++ b/app/assets/javascripts/operation_settings/components/external_dashboard.vue @@ -50,13 +50,18 @@ export default { <form> <gl-form-group :label="s__('ExternalMetrics|Full dashboard URL')" + label-for="full-dashboard-url" :description="s__('ExternalMetrics|Enter the URL of the dashboard you want to link to')" > + <!-- placeholder with a url is a false positive --> + <!-- eslint-disable @gitlab/vue-i18n/no-bare-attribute-strings --> <gl-form-input + id="full-dashboard-url" v-model="userDashboardUrl" placeholder="https://my-org.gitlab.io/my-dashboards" @keydown.enter.native.prevent="updateExternalDashboardUrl" /> + <!-- eslint-enable @gitlab/vue-i18n/no-bare-attribute-strings --> </gl-form-group> <gl-button variant="success" @click="updateExternalDashboardUrl"> {{ __('Save Changes') }} diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js index 6e00e31b828..7a6a486f551 100644 --- a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js +++ b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js @@ -3,26 +3,31 @@ import _ from 'underscore'; import axios from '~/lib/utils/axios_utils'; import flash from '~/flash'; import { __ } from '~/locale'; +import { textColorForBackground } from '~/lib/utils/color_utils'; export default () => { - $('input#broadcast_message_color').on('input', function onMessageColorInput() { + const $broadcastMessageColor = $('input#broadcast_message_color'); + const $broadcastMessagePreview = $('div.broadcast-message-preview'); + $broadcastMessageColor.on('input', function onMessageColorInput() { const previewColor = $(this).val(); - $('div.broadcast-message-preview').css('background-color', previewColor); + $broadcastMessagePreview.css('background-color', previewColor); }); $('input#broadcast_message_font').on('input', function onMessageFontInput() { const previewColor = $(this).val(); - $('div.broadcast-message-preview').css('color', previewColor); + $broadcastMessagePreview.css('color', previewColor); }); - const previewPath = $('textarea#broadcast_message_message').data('previewPath'); + const $broadcastMessage = $('textarea#broadcast_message_message'); + const previewPath = $broadcastMessage.data('previewPath'); + const $jsBroadcastMessagePreview = $('.js-broadcast-message-preview'); - $('textarea#broadcast_message_message').on( + $broadcastMessage.on( 'input', _.debounce(function onMessageInput() { const message = $(this).val(); if (message === '') { - $('.js-broadcast-message-preview').text(__('Your message here')); + $jsBroadcastMessagePreview.text(__('Your message here')); } else { axios .post(previewPath, { @@ -31,10 +36,40 @@ export default () => { }, }) .then(({ data }) => { - $('.js-broadcast-message-preview').html(data.message); + $jsBroadcastMessagePreview.html(data.message); }) .catch(() => flash(__('An error occurred while rendering preview broadcast message'))); } }, 250), ); + + const updateColorPreview = () => { + const selectedBackgroundColor = $broadcastMessageColor.val(); + const contrastTextColor = textColorForBackground(selectedBackgroundColor); + + // save contrastTextColor to hidden input field + $('input.text-font-color').val(contrastTextColor); + + // Updates the preview color with the hex-color input + const selectedColorStyle = { + backgroundColor: selectedBackgroundColor, + color: contrastTextColor, + }; + + $('.label-color-preview').css(selectedColorStyle); + + return $broadcastMessagePreview.css(selectedColorStyle); + }; + + const setSuggestedColor = e => { + const color = $(e.currentTarget).data('color'); + $broadcastMessageColor + .val(color) + // Notify the form, that color has changed + .trigger('input'); + updateColorPreview(); + return e.preventDefault(); + }; + + $(document).on('click', '.suggest-colors a', setSuggestedColor); }; diff --git a/app/assets/javascripts/pages/admin/clusters/index.js b/app/assets/javascripts/pages/admin/clusters/index.js index d0c9ae66c6a..43992938d07 100644 --- a/app/assets/javascripts/pages/admin/clusters/index.js +++ b/app/assets/javascripts/pages/admin/clusters/index.js @@ -1,5 +1,5 @@ import PersistentUserCallout from '~/persistent_user_callout'; -import initGkeDropdowns from '~/projects/gke_cluster_dropdowns'; +import initGkeDropdowns from '~/create_cluster/gke_cluster'; function initGcpSignupCallout() { const callout = document.querySelector('.gcp-signup-offer'); diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js index 1b56b97f751..d51d411f3c6 100644 --- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js +++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js @@ -82,7 +82,7 @@ export default class Todos { }) .catch(() => { this.updateRowState(target, true); - return flash(__('Error updating todo status.')); + return flash(__('Error updating status of to-do item.')); }); } @@ -124,7 +124,7 @@ export default class Todos { this.updateAllState(target, data); this.updateBadges(data); }) - .catch(() => flash(__('Error updating status for all todos.'))); + .catch(() => flash(__('Error updating status for all to-do items.'))); } updateAllState(target, data) { diff --git a/app/assets/javascripts/pages/groups/index.js b/app/assets/javascripts/pages/groups/index.js index 451be6497de..a33d242908b 100644 --- a/app/assets/javascripts/pages/groups/index.js +++ b/app/assets/javascripts/pages/groups/index.js @@ -1,5 +1,5 @@ import PersistentUserCallout from '~/persistent_user_callout'; -import initGkeDropdowns from '~/projects/gke_cluster_dropdowns'; +import initGkeDropdowns from '~/create_cluster/gke_cluster'; function initGcpSignupCallout() { const callout = document.querySelector('.gcp-signup-offer'); diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index 23fb5656008..dcdee77a8ab 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -1,11 +1,15 @@ import projectSelect from '~/project_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; +import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar'; import { FILTERED_SEARCH } from '~/pages/constants'; import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import initManualOrdering from '~/manual_ordering'; +const ISSUE_BULK_UPDATE_PREFIX = 'issue_'; + document.addEventListener('DOMContentLoaded', () => { IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); + issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX); initFilteredSearch({ page: FILTERED_SEARCH.ISSUES, diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js index 12a26fd88fa..7520cfb6da0 100644 --- a/app/assets/javascripts/pages/groups/merge_requests/index.js +++ b/app/assets/javascripts/pages/groups/merge_requests/index.js @@ -1,11 +1,15 @@ import projectSelect from '~/project_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; +import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import { FILTERED_SEARCH } from '~/pages/constants'; +const ISSUABLE_BULK_UPDATE_PREFIX = 'merge_request_'; + document.addEventListener('DOMContentLoaded', () => { addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); + issuableInitBulkUpdateSidebar.init(ISSUABLE_BULK_UPDATE_PREFIX); initFilteredSearch({ page: FILTERED_SEARCH.MERGE_REQUESTS, diff --git a/app/assets/javascripts/pages/projects/branches/index/index.js b/app/assets/javascripts/pages/projects/branches/index/index.js index 29de3b7806c..37e8c75f299 100644 --- a/app/assets/javascripts/pages/projects/branches/index/index.js +++ b/app/assets/javascripts/pages/projects/branches/index/index.js @@ -5,5 +5,5 @@ import initDiverganceGraph from '~/branches/divergence_graph'; document.addEventListener('DOMContentLoaded', () => { AjaxLoadingSpinner.init(); new DeleteModal(); // eslint-disable-line no-new - initDiverganceGraph(); + initDiverganceGraph(document.querySelector('.js-branch-list').dataset.divergingCountsEndpoint); }); diff --git a/app/assets/javascripts/pages/projects/clusters/show/index.js b/app/assets/javascripts/pages/projects/clusters/show/index.js index 8001d2dd1da..f091c01fc98 100644 --- a/app/assets/javascripts/pages/projects/clusters/show/index.js +++ b/app/assets/javascripts/pages/projects/clusters/show/index.js @@ -1,5 +1,7 @@ import ClustersBundle from '~/clusters/clusters_bundle'; +import initGkeNamespace from '~/projects/gke_cluster_namespace'; document.addEventListener('DOMContentLoaded', () => { new ClustersBundle(); // eslint-disable-line no-new + initGkeNamespace(); }); diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js index d4bd02c14e9..196798a9076 100644 --- a/app/assets/javascripts/pages/projects/index.js +++ b/app/assets/javascripts/pages/projects/index.js @@ -1,4 +1,5 @@ -import initGkeDropdowns from '~/projects/gke_cluster_dropdowns'; +import initGkeDropdowns from '~/create_cluster/gke_cluster'; +import initGkeNamespace from '~/projects/gke_cluster_namespace'; import PersistentUserCallout from '../../persistent_user_callout'; import Project from './project'; import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation'; @@ -16,6 +17,7 @@ document.addEventListener('DOMContentLoaded', () => { PersistentUserCallout.factory(callout); initGkeDropdowns(); + initGkeNamespace(); } new Project(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/pages/projects/issues/form.js index 941c4552579..2205a7bafe3 100644 --- a/app/assets/javascripts/pages/projects/issues/form.js +++ b/app/assets/javascripts/pages/projects/issues/form.js @@ -17,7 +17,5 @@ export default () => { new MilestoneSelect(); new IssuableTemplateSelectors(); - if (gon.features.graphql) { - initSuggestions(); - } + initSuggestions(); }; diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index f0d529758d5..332b6811af6 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -1,9 +1,10 @@ -/* eslint-disable func-names, no-var, no-return-assign, one-var, object-shorthand, vars-on-top */ +/* eslint-disable func-names, no-var, no-return-assign, object-shorthand, vars-on-top */ import $ from 'jquery'; import Cookies from 'js-cookie'; import { __ } from '~/locale'; -import { visitUrl } from '~/lib/utils/url_utility'; +import { visitUrl, mergeUrlParams } from '~/lib/utils/url_utility'; +import { serializeForm } from '~/lib/utils/forms'; import axios from '~/lib/utils/axios_utils'; import flash from '~/flash'; import projectSelect from '../../project_select'; @@ -107,9 +108,10 @@ export default class Project { refLink.href = '#'; return $('.js-project-refs-dropdown').each(function() { - var $dropdown, selected; - $dropdown = $(this); - selected = $dropdown.data('selected'); + var $dropdown = $(this); + var selected = $dropdown.data('selected'); + var fieldName = $dropdown.data('fieldName'); + var shouldVisit = Boolean($dropdown.data('visit')); return $dropdown.glDropdown({ data(term, callback) { axios @@ -127,7 +129,7 @@ export default class Project { filterRemote: true, filterByText: true, inputFieldName: $dropdown.data('inputFieldName'), - fieldName: $dropdown.data('fieldName'), + fieldName, renderRow: function(ref) { var li = refListItem.cloneNode(false); @@ -158,15 +160,12 @@ export default class Project { clicked: function(options) { const { e } = options; e.preventDefault(); - if ($('input[name="ref"]').length) { + if ($(`input[name="${fieldName}"]`).length) { var $form = $dropdown.closest('form'); - - var $visit = $dropdown.data('visit'); - var shouldVisit = $visit ? true : $visit; var action = $form.attr('action'); - var divider = action.indexOf('?') === -1 ? '?' : '&'; + if (shouldVisit) { - visitUrl(`${action}${divider}${$form.serialize()}`); + visitUrl(mergeUrlParams(serializeForm($form[0]), action)); } } }, diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue index ff6dadeff7d..533065b2d4d 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue @@ -1,5 +1,6 @@ <script> -import projectFeatureToggle from '../../../../../vue_shared/components/toggle_button.vue'; +import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue'; +import { featureAccessLevelNone } from '../constants'; export default { components: { @@ -43,7 +44,7 @@ export default { if (this.featureEnabled) { return this.options; } - return [[0, 'Enable feature to choose access level']]; + return [featureAccessLevelNone]; }, displaySelectInput() { diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index 0bcfb740469..a223a8f5b08 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -1,11 +1,20 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin'; +import { __ } from '~/locale'; import projectFeatureSetting from './project_feature_setting.vue'; import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue'; import projectSettingRow from './project_setting_row.vue'; -import { visibilityOptions, visibilityLevelDescriptions } from '../constants'; +import { + visibilityOptions, + visibilityLevelDescriptions, + featureAccessLevelMembers, + featureAccessLevelEveryone, +} from '../constants'; import { toggleHiddenClassBySelector } from '../external'; +const PAGE_FEATURE_ACCESS_LEVEL = __('Everyone'); + export default { components: { projectFeatureSetting, @@ -19,6 +28,11 @@ export default { type: Object, required: true, }, + canDisableEmails: { + type: Boolean, + required: false, + default: false, + }, canChangeVisibilityLevel: { type: Boolean, required: false, @@ -95,6 +109,7 @@ export default { lfsEnabled: true, requestAccessEnabled: true, highlightChangesClass: false, + emailsDisabled: false, }; return { ...defaults, ...this.currentSettings }; @@ -102,9 +117,9 @@ export default { computed: { featureAccessLevelOptions() { - const options = [[10, 'Only Project Members']]; + const options = [featureAccessLevelMembers]; if (this.visibilityLevel !== visibilityOptions.PRIVATE) { - options.push([20, 'Everyone With Access']); + options.push(featureAccessLevelEveryone); } return options; }, @@ -117,7 +132,7 @@ export default { pagesFeatureAccessLevelOptions() { if (this.visibilityLevel !== visibilityOptions.PUBLIC) { - return this.featureAccessLevelOptions.concat([[30, 'Everyone']]); + return this.featureAccessLevelOptions.concat([[30, PAGE_FEATURE_ACCESS_LEVEL]]); } return this.featureAccessLevelOptions; }, @@ -200,17 +215,17 @@ export default { <option :value="visibilityOptions.PRIVATE" :disabled="!visibilityAllowed(visibilityOptions.PRIVATE)" - >Private</option + >{{ __('Private') }}</option > <option :value="visibilityOptions.INTERNAL" :disabled="!visibilityAllowed(visibilityOptions.INTERNAL)" - >Internal</option + >{{ __('Internal') }}</option > <option :value="visibilityOptions.PUBLIC" :disabled="!visibilityAllowed(visibilityOptions.PUBLIC)" - >Public</option + >{{ __('Public') }}</option > </select> <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i> @@ -332,5 +347,14 @@ export default { /> </project-setting-row> </div> + <project-setting-row v-if="canDisableEmails" class="mb-3"> + <label class="js-emails-disabled"> + <input :value="emailsDisabled" type="hidden" name="project[emails_disabled]" /> + <input v-model="emailsDisabled" type="checkbox" /> {{ __('Disable email notifications') }} + </label> + <span class="form-text text-muted">{{ + __('This setting will override user notification preferences for all project members.') + }}</span> + </project-setting-row> </div> </template> diff --git a/app/assets/javascripts/pages/projects/shared/permissions/constants.js b/app/assets/javascripts/pages/projects/shared/permissions/constants.js index ac0dca31c37..73269c6f3ba 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/constants.js +++ b/app/assets/javascripts/pages/projects/shared/permissions/constants.js @@ -15,3 +15,30 @@ export const visibilityLevelDescriptions = { 'The project can be accessed by anyone, regardless of authentication.', ), }; + +const featureAccessLevel = { + NOT_ENABLED: 0, + PROJECT_MEMBERS: 10, + EVERYONE: 20, +}; + +const featureAccessLevelDescriptions = { + [featureAccessLevel.NOT_ENABLED]: __('Enable feature to choose access level'), + [featureAccessLevel.PROJECT_MEMBERS]: __('Only Project Members'), + [featureAccessLevel.EVERYONE]: __('Everyone With Access'), +}; + +export const featureAccessLevelNone = [ + featureAccessLevel.NOT_ENABLED, + featureAccessLevelDescriptions[featureAccessLevel.NOT_ENABLED], +]; + +export const featureAccessLevelMembers = [ + featureAccessLevel.PROJECT_MEMBERS, + featureAccessLevelDescriptions[featureAccessLevel.PROJECT_MEMBERS], +]; + +export const featureAccessLevelEveryone = [ + featureAccessLevel.EVERYONE, + featureAccessLevelDescriptions[featureAccessLevel.EVERYONE], +]; diff --git a/app/assets/javascripts/pages/projects/wikis/wikis.js b/app/assets/javascripts/pages/projects/wikis/wikis.js index 9b58d42b47d..d41199f6374 100644 --- a/app/assets/javascripts/pages/projects/wikis/wikis.js +++ b/app/assets/javascripts/pages/projects/wikis/wikis.js @@ -1,6 +1,5 @@ import bp from '../../../breakpoints'; -import { parseQueryStringIntoObject } from '../../../lib/utils/common_utils'; -import { mergeUrlParams, redirectTo } from '../../../lib/utils/url_utility'; +import { s__, sprintf } from '~/locale'; export default class Wikis { constructor() { @@ -12,32 +11,37 @@ export default class Wikis { sidebarToggles[i].addEventListener('click', e => this.handleToggleSidebar(e)); } - this.newWikiForm = document.querySelector('form.new-wiki-page'); - if (this.newWikiForm) { - this.newWikiForm.addEventListener('submit', e => this.handleNewWikiSubmit(e)); + this.isNewWikiPage = Boolean(document.querySelector('.js-new-wiki-page')); + this.editTitleInput = document.querySelector('form.wiki-form #wiki_title'); + this.commitMessageInput = document.querySelector('form.wiki-form #wiki_message'); + this.commitMessageI18n = this.isNewWikiPage + ? s__('WikiPageCreate|Create %{pageTitle}') + : s__('WikiPageEdit|Update %{pageTitle}'); + + if (this.editTitleInput) { + // Initialize the commit message on load + if (this.editTitleInput.value) this.setWikiCommitMessage(this.editTitleInput.value); + + // Set the commit message as the page title is changed + this.editTitleInput.addEventListener('keyup', e => this.handleWikiTitleChange(e)); } window.addEventListener('resize', () => this.renderSidebar()); this.renderSidebar(); } - handleNewWikiSubmit(e) { - if (!this.newWikiForm) return; - - const slugInput = this.newWikiForm.querySelector('#new_wiki_path'); - - const slug = slugInput.value; + handleWikiTitleChange(e) { + this.setWikiCommitMessage(e.target.value); + } - if (slug.length > 0) { - const wikisPath = slugInput.getAttribute('data-wikis-path'); + setWikiCommitMessage(rawTitle) { + let title = rawTitle; - // If the wiki is empty, we need to merge the current URL params to keep the "create" view. - const params = parseQueryStringIntoObject(window.location.search.substr(1)); - const url = mergeUrlParams(params, `${wikisPath}/${slug}`); - redirectTo(url); + // Replace hyphens with spaces + if (title) title = title.replace(/-+/g, ' '); - e.preventDefault(); - } + const newCommitMessage = sprintf(this.commitMessageI18n, { pageTitle: title }); + this.commitMessageInput.value = newCommitMessage; } handleToggleSidebar(e) { diff --git a/app/assets/javascripts/pages/search/show/refresh_counts.js b/app/assets/javascripts/pages/search/show/refresh_counts.js new file mode 100644 index 00000000000..fa75ee6075d --- /dev/null +++ b/app/assets/javascripts/pages/search/show/refresh_counts.js @@ -0,0 +1,24 @@ +import axios from '~/lib/utils/axios_utils'; + +function showCount(el, count) { + el.textContent = count; + el.classList.remove('hidden'); +} + +function refreshCount(el) { + const { url } = el.dataset; + + return axios + .get(url) + .then(({ data }) => showCount(el, data.count)) + .catch(e => { + // eslint-disable-next-line no-console + console.error(`Failed to fetch search count from '${url}'.`, e); + }); +} + +export default function refreshCounts() { + const elements = Array.from(document.querySelectorAll('.js-search-count')); + + return Promise.all(elements.map(refreshCount)); +} diff --git a/app/assets/javascripts/pages/search/show/search.js b/app/assets/javascripts/pages/search/show/search.js index d5a8e712d6b..86ec78e1df8 100644 --- a/app/assets/javascripts/pages/search/show/search.js +++ b/app/assets/javascripts/pages/search/show/search.js @@ -2,6 +2,8 @@ import $ from 'jquery'; import Flash from '~/flash'; import Api from '~/api'; import { __ } from '~/locale'; +import Project from '~/pages/projects/project'; +import refreshCounts from './refresh_counts'; export default class Search { constructor() { @@ -13,6 +15,7 @@ export default class Search { this.groupId = $groupDropdown.data('groupId'); this.eventListeners(); + refreshCounts(); $groupDropdown.glDropdown({ selectable: true, @@ -37,9 +40,6 @@ export default class Search { text(obj) { return obj.full_name; }, - toggleLabel(obj) { - return `${$groupDropdown.data('defaultLabel')} ${obj.full_name}`; - }, clicked: () => Search.submitSearch(), }); @@ -70,11 +70,10 @@ export default class Search { text(obj) { return obj.name_with_namespace; }, - toggleLabel(obj) { - return `${$projectDropdown.data('defaultLabel')} ${obj.name_with_namespace}`; - }, clicked: () => Search.submitSearch(), }); + + Project.initRefSwitcher(); } eventListeners() { diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js index 1d8b388e935..4ac4efec45d 100644 --- a/app/assets/javascripts/pages/users/user_tabs.js +++ b/app/assets/javascripts/pages/users/user_tabs.js @@ -143,7 +143,7 @@ export default class UserTabs { this.loadOverviewTab(); } - const loadableActions = ['groups', 'contributed', 'projects', 'snippets']; + const loadableActions = ['groups', 'contributed', 'projects', 'starred', 'snippets']; if (loadableActions.indexOf(action) > -1) { this.loadTab(action, endpoint); } diff --git a/app/assets/javascripts/pdf/index.vue b/app/assets/javascripts/pdf/index.vue index 6d39abd4a1f..bbbd9789dc9 100644 --- a/app/assets/javascripts/pdf/index.vue +++ b/app/assets/javascripts/pdf/index.vue @@ -14,7 +14,6 @@ export default { }, data() { return { - loading: false, pages: [], }; }, @@ -35,19 +34,24 @@ export default { load() { this.pages = []; return pdfjsLib - .getDocument(this.document) - .then(this.renderPages) - .then(() => this.$emit('pdflabload')) - .catch(error => this.$emit('pdflaberror', error)) - .then(() => { - this.loading = false; + .getDocument({ + url: this.document, + cMapUrl: '/assets/webpack/cmaps/', + cMapPacked: true, + }) + .promise.then(this.renderPages) + .then(pages => { + this.pages = pages; + this.$emit('pdflabload'); + }) + .catch(error => { + this.$emit('pdflaberror', error); }); }, renderPages(pdf) { const pagePromises = []; - this.loading = true; for (let num = 1; num <= pdf.numPages; num += 1) { - pagePromises.push(pdf.getPage(num).then(p => this.pages.push(p))); + pagePromises.push(pdf.getPage(num)); } return Promise.all(pagePromises); }, @@ -59,8 +63,8 @@ export default { <div v-if="hasPDF" class="pdf-viewer"> <page v-for="(page, index) in pages" + v-if="page" :key="index" - :v-if="!loading" :page="page" :number="index + 1" /> diff --git a/app/assets/javascripts/pdf/page/index.vue b/app/assets/javascripts/pdf/page/index.vue index f16aaca6cd7..65f84e75e86 100644 --- a/app/assets/javascripts/pdf/page/index.vue +++ b/app/assets/javascripts/pdf/page/index.vue @@ -18,7 +18,7 @@ export default { }, computed: { viewport() { - return this.page.getViewport(this.scale); + return this.page.getViewport({ scale: this.scale }); }, context() { return this.$refs.canvas.getContext('2d'); @@ -36,10 +36,12 @@ export default { this.rendering = true; this.page .render(this.renderContext) - .then(() => { + .promise.then(() => { this.rendering = false; }) - .catch(error => this.$emit('pdflaberror', error)); + .catch(error => { + this.$emit('pdflaberror', error); + }); }, }; </script> diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index d5f1cea8356..a271284dd89 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -16,11 +16,14 @@ export default { type: String, required: true, }, - header: { + title: { type: String, - required: true, + required: false, + default() { + return this.metric; + }, }, - details: { + header: { type: String, required: true, }, @@ -34,14 +37,14 @@ export default { return this.currentRequest.details[this.metric]; }, detailsList() { - return this.metricDetails[this.details]; + return this.metricDetails.details; }, }, }; </script> <template> <div - v-if="currentRequest.details" + v-if="currentRequest.details && metricDetails" :id="`peek-view-${metric}`" class="view qa-performance-bar-detailed-metric" > @@ -63,7 +66,7 @@ export default { <template v-if="detailsList.length"> <tr v-for="(item, index) in detailsList" :key="index"> <td> - <span>{{ item.duration }}ms</span> + <span>{{ sprintf(__('%{duration}ms'), { duration: item.duration }) }}</span> </td> <td> <div class="js-toggle-container"> @@ -101,6 +104,6 @@ export default { <div slot="footer"></div> </gl-modal> - {{ metric }} + {{ title }} </div> </template> diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index 185003c306e..9ad6e75b86b 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -1,17 +1,14 @@ <script> -import $ from 'jquery'; import { glEmojiTag } from '~/emoji'; import detailedMetric from './detailed_metric.vue'; import requestSelector from './request_selector.vue'; -import simpleMetric from './simple_metric.vue'; import { s__ } from '~/locale'; export default { components: { detailedMetric, requestSelector, - simpleMetric, }, props: { store: { @@ -30,21 +27,30 @@ export default { type: String, required: true, }, - profileUrl: { - type: String, - required: true, - }, }, detailedMetrics: [ - { metric: 'pg', header: s__('PerformanceBar|SQL queries'), details: 'queries', keys: ['sql'] }, + { + metric: 'active-record', + title: 'pg', + header: s__('PerformanceBar|SQL queries'), + keys: ['sql'], + }, { metric: 'gitaly', header: s__('PerformanceBar|Gitaly calls'), - details: 'details', keys: ['feature', 'request'], }, + { + metric: 'rugged', + header: s__('PerformanceBar|Rugged calls'), + keys: ['feature', 'args'], + }, + { + metric: 'redis', + header: s__('PerformanceBar|Redis calls'), + keys: ['cmd'], + }, ], - simpleMetrics: ['redis'], data() { return { currentRequestId: '' }; }, @@ -63,9 +69,6 @@ export default { initialRequest() { return this.currentRequestId === this.requestId; }, - lineProfileModal() { - return $('#modal-peek-line-profile'); - }, hasHost() { return this.currentRequest && this.currentRequest.details && this.currentRequest.details.host; }, @@ -73,16 +76,11 @@ export default { if (this.hasHost && this.currentRequest.details.host.canary) { return glEmojiTag('baby_chick'); } - return ''; }, }, mounted() { this.currentRequest = this.requestId; - - if (this.lineProfileModal.length) { - this.lineProfileModal.modal('toggle'); - } }, methods: { changeCurrentRequest(newRequestId) { @@ -109,33 +107,10 @@ export default { :key="metric.metric" :current-request="currentRequest" :metric="metric.metric" + :title="metric.title" :header="metric.header" - :details="metric.details" :keys="metric.keys" /> - <div v-if="initialRequest" id="peek-view-rblineprof" class="view"> - <button - v-if="lineProfileModal.length" - class="btn-link btn-blank" - data-toggle="modal" - data-target="#modal-peek-line-profile" - > - {{ s__('PerformanceBar|profile') }} - </button> - <a v-else :href="profileUrl">{{ s__('PerformanceBar|profile') }}</a> - </div> - <simple-metric - v-for="metric in $options.simpleMetrics" - :key="metric" - :current-request="currentRequest" - :metric="metric" - /> - <div id="peek-view-gc" class="view"> - <span v-if="currentRequest.details" class="bold"> - <span title="Invoke Time">{{ currentRequest.details.gc.gc_time }}</span - >ms / <span title="Invoke Count">{{ currentRequest.details.gc.invokes }}</span> gc - </span> - </div> <div v-if="currentRequest.details && currentRequest.details.tracing" id="peek-view-trace" diff --git a/app/assets/javascripts/performance_bar/components/simple_metric.vue b/app/assets/javascripts/performance_bar/components/simple_metric.vue deleted file mode 100644 index 358a57d5bc5..00000000000 --- a/app/assets/javascripts/performance_bar/components/simple_metric.vue +++ /dev/null @@ -1,33 +0,0 @@ -<script> -export default { - props: { - currentRequest: { - type: Object, - required: true, - }, - metric: { - type: String, - required: true, - }, - }, - computed: { - duration() { - return ( - this.currentRequest.details[this.metric] && - this.currentRequest.details[this.metric].duration - ); - }, - calls() { - return ( - this.currentRequest.details[this.metric] && this.currentRequest.details[this.metric].calls - ); - }, - }, -}; -</script> -<template> - <div :id="`peek-view-${metric}`" class="view"> - <span v-if="currentRequest.details" class="bold"> {{ duration }} / {{ calls }} </span> - {{ metric }} - </div> -</template> diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js index 4a08e158f6b..8d6a3781048 100644 --- a/app/assets/javascripts/persistent_user_callout.js +++ b/app/assets/javascripts/persistent_user_callout.js @@ -1,13 +1,17 @@ +import { parseBoolean } from './lib/utils/common_utils'; import axios from './lib/utils/axios_utils'; import { __ } from './locale'; import Flash from './flash'; +const DEFERRED_LINK_CLASS = 'deferred-link'; + export default class PersistentUserCallout { constructor(container) { - const { dismissEndpoint, featureId } = container.dataset; + const { dismissEndpoint, featureId, deferLinks } = container.dataset; this.container = container; this.dismissEndpoint = dismissEndpoint; this.featureId = featureId; + this.deferLinks = parseBoolean(deferLinks); this.init(); } @@ -15,9 +19,21 @@ export default class PersistentUserCallout { init() { const closeButton = this.container.querySelector('.js-close'); closeButton.addEventListener('click', event => this.dismiss(event)); + + if (this.deferLinks) { + this.container.addEventListener('click', event => { + const isDeferredLink = event.target.classList.contains(DEFERRED_LINK_CLASS); + + if (isDeferredLink) { + const { href, target } = event.target; + + this.dismiss(event, { href, target }); + } + }); + } } - dismiss(event) { + dismiss(event, deferredLinkOptions = null) { event.preventDefault(); axios @@ -26,6 +42,11 @@ export default class PersistentUserCallout { }) .then(() => { this.container.remove(); + + if (deferredLinkOptions) { + const { href, target } = deferredLinkOptions; + window.open(href, target); + } }) .catch(() => { Flash(__('An error occurred while dismissing the alert. Refresh the page and try again.')); diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index b2e365e5cde..39afa87afc3 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -2,6 +2,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import ciHeader from '../../vue_shared/components/header_ci_component.vue'; import eventHub from '../event_hub'; +import { __ } from '~/locale'; export default { name: 'PipelineHeaderSection', @@ -54,7 +55,7 @@ export default { if (this.pipeline.retry_path) { actions.push({ - label: 'Retry', + label: __('Retry'), path: this.pipeline.retry_path, cssClass: 'js-retry-button btn btn-inverted-secondary', type: 'button', @@ -64,7 +65,7 @@ export default { if (this.pipeline.cancel_path) { actions.push({ - label: 'Cancel running', + label: __('Cancel running'), path: this.pipeline.cancel_path, cssClass: 'js-btn-cancel-pipeline btn btn-danger', type: 'button', diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue index 65a2b61396c..a08f732dda7 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue @@ -67,7 +67,7 @@ export default { <span v-if="pipeline.flags.latest" v-gl-tooltip - :title="__('Latest pipeline for this branch')" + :title="__('Latest pipeline for the most recent commit on this branch')" class="js-pipeline-url-latest badge badge-success" > {{ __('latest') }} @@ -94,16 +94,19 @@ export default { tabindex="0" class="js-pipeline-url-autodevops badge badge-info autodevops-badge" role="button" + >{{ __('Auto DevOps') }}</gl-link > - Auto DevOps - </gl-link> <span v-if="pipeline.flags.stuck" class="js-pipeline-url-stuck badge badge-warning"> {{ __('stuck') }} </span> <span v-if="pipeline.flags.detached_merge_request_pipeline" v-gl-tooltip - :title="__('This pipeline is run on the source branch')" + :title=" + __( + 'Pipelines for merge requests are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more on the documentation for Pipelines for Merged Results.', + ) + " class="js-pipeline-url-detached badge badge-info" > {{ __('detached') }} diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_actions.vue index 244d332f38f..4b2d816c6a0 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_actions.vue @@ -1,9 +1,11 @@ <script> import { GlButton, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui'; -import { s__, sprintf } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; +import flash from '~/flash'; +import { s__, __, sprintf } from '~/locale'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; +import Icon from '~/vue_shared/components/icon.vue'; import eventHub from '../event_hub'; -import Icon from '../../vue_shared/components/icon.vue'; export default { directives: { @@ -44,7 +46,24 @@ export default { this.isLoading = true; - eventHub.$emit('postAction', action.path); + /** + * Ideally, the component would not make an api call directly. + * However, in order to use the eventhub and know when to + * toggle back the `isLoading` property we'd need an ID + * to track the request with a wacther - since this component + * is rendered at least 20 times in the same page, moving the + * api call directly here is the most performant solution + */ + axios + .post(`${action.path}.json`) + .then(() => { + this.isLoading = false; + eventHub.$emit('updateTable'); + }) + .catch(() => { + this.isLoading = false; + flash(__('An error occurred while making the request.')); + }); }, isActionDisabled(action) { diff --git a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue index 2ab0ad4d013..3f07b77ed32 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue @@ -1,4 +1,5 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { GlLink, GlTooltipDirective } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; diff --git a/app/assets/javascripts/pipelines/components/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_table.vue index 03d332cd430..d3ba0c97f6b 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table.vue @@ -44,6 +44,11 @@ export default { cancelingPipeline: null, }; }, + watch: { + pipelines() { + this.cancelingPipeline = null; + }, + }, created() { eventHub.$on('openConfirmationModal', this.setModalData); }, diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue index e32e2f785bd..5275de3bc8b 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue @@ -241,7 +241,11 @@ export default { return this.cancelingPipeline === this.pipeline.id; }, }, - + watch: { + pipeline() { + this.isRetrying = false; + }, + }, methods: { handleCancelClick() { eventHub.$emit('openConfirmationModal', { diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js index 3cc9d0a3a4e..126a9a47a2b 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines.js @@ -60,12 +60,14 @@ export default { eventHub.$on('postAction', this.postAction); eventHub.$on('retryPipeline', this.postAction); eventHub.$on('clickedDropdown', this.updateTable); + eventHub.$on('updateTable', this.updateTable); eventHub.$on('refreshPipelinesTable', this.fetchPipelines); }, beforeDestroy() { eventHub.$off('postAction', this.postAction); eventHub.$off('retryPipeline', this.postAction); eventHub.$off('clickedDropdown', this.updateTable); + eventHub.$off('updateTable', this.updateTable); eventHub.$off('refreshPipelinesTable', this.fetchPipelines); }, destroyed() { @@ -107,8 +109,8 @@ export default { } // Stop polling this.poll.stop(); - // Update the table - return this.getPipelines().then(() => this.poll.restart()); + // Restarting the poll also makes an initial request + this.poll.restart(); }, fetchPipelines() { if (!this.isMakingRequest) { @@ -153,7 +155,7 @@ export default { postAction(endpoint) { this.service .postAction(endpoint) - .then(() => this.fetchPipelines()) + .then(() => this.updateTable()) .catch(() => Flash(__('An error occurred while making the request.'))); }, }, diff --git a/app/assets/javascripts/privacy_policy_update_callout.js b/app/assets/javascripts/privacy_policy_update_callout.js new file mode 100644 index 00000000000..126b1ee1132 --- /dev/null +++ b/app/assets/javascripts/privacy_policy_update_callout.js @@ -0,0 +1,8 @@ +import PersistentUserCallout from '~/persistent_user_callout'; + +function initPrivacyPolicyUpdateCallout() { + const callout = document.querySelector('.privacy-policy-update-64341'); + PersistentUserCallout.factory(callout); +} + +export default initPrivacyPolicyUpdateCallout; diff --git a/app/assets/javascripts/projects/gke_cluster_namespace/index.js b/app/assets/javascripts/projects/gke_cluster_namespace/index.js new file mode 100644 index 00000000000..0ec4d8807b0 --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_namespace/index.js @@ -0,0 +1,37 @@ +/** + * Disables & hides the namespace inputs when the gitlab-managed checkbox is checked/unchecked. + */ + +const setDisabled = (el, isDisabled) => { + if (isDisabled) { + el.classList.add('hidden'); + el.querySelector('input').setAttribute('disabled', true); + } else { + el.classList.remove('hidden'); + el.querySelector('input').removeAttribute('disabled'); + } +}; + +const setState = glManagedCheckbox => { + const glManaged = document.querySelector('.js-namespace-prefixed'); + const selfManaged = document.querySelector('.js-namespace'); + + if (glManagedCheckbox.checked) { + setDisabled(glManaged, false); + setDisabled(selfManaged, true); + } else { + setDisabled(glManaged, true); + setDisabled(selfManaged, false); + } +}; + +const initGkeNamespace = () => { + const glManagedCheckbox = document.querySelector('.js-gl-managed'); + + if (glManagedCheckbox) { + setState(glManagedCheckbox); // this is needed in order to set the initial state + glManagedCheckbox.addEventListener('change', () => setState(glManagedCheckbox)); + } +}; + +export default initGkeNamespace; diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index ea82ff4e340..9066844f687 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils'; -import { slugifyWithHyphens } from '../lib/utils/text_utility'; +import { slugify } from '../lib/utils/text_utility'; import { s__ } from '~/locale'; let hasUserDefinedProjectPath = false; @@ -34,7 +34,7 @@ const deriveProjectPathFromUrl = $projectImportUrl => { }; const onProjectNameChange = ($projectNameInput, $projectPathInput) => { - const slug = slugifyWithHyphens($projectNameInput.val()); + const slug = slugify($projectNameInput.val()); $projectPathInput.val(slug); }; diff --git a/app/assets/javascripts/projects/projects_filterable_list.js b/app/assets/javascripts/projects/projects_filterable_list.js new file mode 100644 index 00000000000..433c894e668 --- /dev/null +++ b/app/assets/javascripts/projects/projects_filterable_list.js @@ -0,0 +1,7 @@ +import FilterableList from '~/filterable_list'; + +export default class ProjectsFilterableList extends FilterableList { + getFilterEndpoint() { + return this.getPagePath().replace('/projects?', '/projects.json?'); + } +} diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue index bfc55013a71..12ee1ce2f0c 100644 --- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue +++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue @@ -3,7 +3,7 @@ import Visibility from 'visibilityjs'; import ciIcon from '~/vue_shared/components/ci_icon.vue'; import Poll from '~/lib/utils/poll'; import Flash from '~/flash'; -import { s__, sprintf } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; import tooltip from '~/vue_shared/directives/tooltip'; import { GlLoadingIcon } from '@gitlab/ui'; import CommitPipelineService from '../services/commit_pipeline_service'; @@ -38,7 +38,9 @@ export default { }, computed: { statusTitle() { - return sprintf(s__('Commits|Commit: %{commitText}'), { commitText: this.ciStatus.text }); + return sprintf(s__('PipelineStatusTooltip|Pipeline: %{ciStatus}'), { + ciStatus: this.ciStatus.text, + }); }, }, mounted() { @@ -56,7 +58,7 @@ export default { }, errorCallback() { this.ciStatus = { - text: 'not found', + text: __('not found'), icon: 'status_notfound', group: 'notfound', }; diff --git a/app/assets/javascripts/projects_list.js b/app/assets/javascripts/projects_list.js index c67d59d2be5..913b62ba26d 100644 --- a/app/assets/javascripts/projects_list.js +++ b/app/assets/javascripts/projects_list.js @@ -1,4 +1,4 @@ -import FilterableList from './filterable_list'; +import ProjectsFilterableList from './projects/projects_filterable_list'; /** * Makes search request for projects when user types a value in the search input. @@ -11,7 +11,7 @@ export default class ProjectsList { const holder = document.querySelector('.js-projects-list-holder'); if (form && filter && holder) { - const list = new FilterableList(form, filter, holder); + const list = new ProjectsFilterableList(form, filter, holder); list.initSearch(); } } diff --git a/app/assets/javascripts/registry/components/app.vue b/app/assets/javascripts/registry/components/app.vue index ee973017387..346dc470a59 100644 --- a/app/assets/javascripts/registry/components/app.vue +++ b/app/assets/javascripts/registry/components/app.vue @@ -1,13 +1,17 @@ <script> import { mapGetters, mapActions } from 'vuex'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; import store from '../stores'; +import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; import CollapsibleContainer from './collapsible_container.vue'; +import { s__, sprintf } from '../../locale'; export default { name: 'RegistryListApp', components: { + clipboardButton, CollapsibleContainer, + GlEmptyState, GlLoadingIcon, }, props: { @@ -15,16 +19,82 @@ export default { type: String, required: true, }, + characterError: { + type: Boolean, + required: false, + default: false, + }, + helpPagePath: { + type: String, + required: true, + }, + noContainersImage: { + type: String, + required: true, + }, + containersErrorImage: { + type: String, + required: true, + }, + repositoryUrl: { + type: String, + required: true, + }, }, store, computed: { ...mapGetters(['isLoading', 'repos']), + dockerConnectionErrorText() { + return sprintf( + s__(`ContainerRegistry|We are having trouble connecting to Docker, which could be due to an + issue with your project name or path. + %{docLinkStart}More Information%{docLinkEnd}`), + { + docLinkStart: `<a href="${this.helpPagePath}#docker-connection-error" target="_blank">`, + docLinkEnd: '</a>', + }, + false, + ); + }, + introText() { + return sprintf( + s__(`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every + project can have its own space to store its Docker images. + %{docLinkStart}More Information%{docLinkEnd}`), + { + docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`, + docLinkEnd: '</a>', + }, + false, + ); + }, + noContainerImagesText() { + return sprintf( + s__(`ContainerRegistry|With the Container Registry, every project can have its own space to + store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`), + { + docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`, + docLinkEnd: '</a>', + }, + false, + ); + }, + dockerBuildCommand() { + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings + return `docker build -t ${this.repositoryUrl} .`; + }, + dockerPushCommand() { + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings + return `docker push ${this.repositoryUrl}`; + }, }, created() { this.setMainEndpoint(this.endpoint); }, mounted() { - this.fetchRepos(); + if (!this.characterError) { + this.fetchRepos(); + } }, methods: { ...mapActions(['setMainEndpoint', 'fetchRepos']), @@ -33,20 +103,63 @@ export default { </script> <template> <div> - <gl-loading-icon v-if="isLoading" size="md" /> + <gl-empty-state + v-if="characterError" + :title="s__('ContainerRegistry|Docker connection error')" + :svg-path="containersErrorImage" + > + <template #description> + <p v-html="dockerConnectionErrorText"></p> + </template> + </gl-empty-state> + + <gl-loading-icon v-else-if="isLoading" size="md" class="prepend-top-16" /> + + <div v-else-if="!isLoading && repos.length"> + <h4>{{ s__('ContainerRegistry|Container Registry') }}</h4> + <p v-html="introText"></p> + <collapsible-container v-for="item in repos" :key="item.id" :repo="item" /> + </div> + + <gl-empty-state + v-else + :title="s__('ContainerRegistry|There are no container images stored for this project')" + :svg-path="noContainersImage" + class="container-message" + > + <template #description> + <p class="js-no-container-images-text" v-html="noContainerImagesText"></p> + <h5>{{ s__('ContainerRegistry|Quick Start') }}</h5> + <p> + {{ + s__( + 'ContainerRegistry|You can add an image to this registry with the following commands:', + ) + }} + </p> - <collapsible-container - v-for="item in repos" - v-else-if="!isLoading && repos.length" - :key="item.id" - :repo="item" - /> + <div class="input-group append-bottom-10"> + <input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly /> + <span class="input-group-append"> + <clipboard-button + :text="dockerBuildCommand" + :title="s__('ContainerRegistry|Copy build command to clipboard')" + class="input-group-text" + /> + </span> + </div> - <p v-else-if="!isLoading && !repos.length"> - {{ - __(`No container images stored for this project. - Add one by following the instructions above.`) - }} - </p> + <div class="input-group"> + <input :value="dockerPushCommand" type="text" class="form-control monospace" readonly /> + <span class="input-group-append"> + <clipboard-button + :text="dockerPushCommand" + :title="s__('ContainerRegistry|Copy push command to clipboard')" + class="input-group-text" + /> + </span> + </div> + </template> + </gl-empty-state> </div> </template> diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue index 1e266dd4ced..bfb2305c48c 100644 --- a/app/assets/javascripts/registry/components/collapsible_container.vue +++ b/app/assets/javascripts/registry/components/collapsible_container.vue @@ -31,6 +31,7 @@ export default { data() { return { isOpen: false, + modalId: `confirm-repo-deletion-modal-${this.repo.id}`, }; }, computed: { @@ -80,10 +81,10 @@ export default { <gl-button v-if="repo.canDelete" v-gl-tooltip - v-gl-modal="'confirm-repo-deletion-modal'" + v-gl-modal="modalId" :title="s__('ContainerRegistry|Remove repository')" :aria-label="s__('ContainerRegistry|Remove repository')" - class="js-remove-repo" + class="js-remove-repo btn-inverted" variant="danger" > <icon name="remove" /> @@ -100,12 +101,7 @@ export default { {{ s__('ContainerRegistry|No tags in Container Registry for this container image.') }} </div> </div> - - <gl-modal - modal-id="confirm-repo-deletion-modal" - ok-variant="danger" - @ok="handleDeleteRepository" - > + <gl-modal :modal-id="modalId" ok-variant="danger" @ok="handleDeleteRepository"> <template v-slot:modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template> <p v-html=" diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue index 0ec5e2c7a87..e9067bc2b56 100644 --- a/app/assets/javascripts/registry/components/table_registry.vue +++ b/app/assets/javascripts/registry/components/table_registry.vue @@ -1,7 +1,13 @@ <script> import { mapActions } from 'vuex'; -import { GlButton, GlTooltipDirective, GlModal, GlModalDirective } from '@gitlab/ui'; -import { n__ } from '../../locale'; +import { + GlButton, + GlFormCheckbox, + GlTooltipDirective, + GlModal, + GlModalDirective, +} from '@gitlab/ui'; +import { n__, s__, sprintf } from '../../locale'; import createFlash from '../../flash'; import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; import TablePagination from '../../vue_shared/components/pagination/table_pagination.vue'; @@ -14,6 +20,7 @@ export default { components: { ClipboardButton, TablePagination, + GlFormCheckbox, GlButton, Icon, GlModal, @@ -31,32 +38,98 @@ export default { }, data() { return { - itemToBeDeleted: null, + itemsToBeDeleted: [], + modalId: `confirm-image-deletion-modal-${this.repo.id}`, + selectAllChecked: false, + modalDescription: '', }; }, computed: { + bulkDeletePath() { + return this.repo.tagsPath ? this.repo.tagsPath.replace('?format=json', '/bulk_destroy') : ''; + }, shouldRenderPagination() { return this.repo.pagination.total > this.repo.pagination.perPage; }, + modalTitle() { + return n__( + 'ContainerRegistry|Remove image', + 'ContainerRegistry|Remove images', + this.itemsToBeDeleted.length === 0 ? 1 : this.itemsToBeDeleted.length, + ); + }, + }, + mounted() { + this.$refs.deleteModal.$refs.modal.$on('hide', this.removeModalEvents); }, methods: { - ...mapActions(['fetchList', 'deleteItem']), + ...mapActions(['fetchList', 'deleteItem', 'multiDeleteItems']), + setModalDescription(itemIndex = -1) { + if (itemIndex === -1) { + this.modalDescription = sprintf( + s__(`ContainerRegistry|You are about to delete <b>%{count}</b> images. This will + delete the images and all tags pointing to them.`), + { count: this.itemsToBeDeleted.length }, + ); + } else { + const { tag } = this.repo.list[itemIndex]; + + this.modalDescription = sprintf( + s__(`ContainerRegistry|You are about to delete the image <b>%{title}</b>. This will + delete the image and all tags pointing to this image.`), + { title: `${this.repo.name}:${tag}` }, + ); + } + }, layers(item) { return item.layers ? n__('%d layer', '%d layers', item.layers) : ''; }, formatSize(size) { return numberToHumanSize(size); }, - setItemToBeDeleted(item) { - this.itemToBeDeleted = item; + removeModalEvents() { + this.$refs.deleteModal.$refs.modal.$off('ok'); }, - handleDeleteRegistry() { - const { itemToBeDeleted } = this; - this.itemToBeDeleted = null; - this.deleteItem(itemToBeDeleted) + deleteSingleItem(index) { + this.setModalDescription(index); + + this.$refs.deleteModal.$refs.modal.$once('ok', () => { + this.removeModalEvents(); + this.handleSingleDelete(this.repo.list[index]); + }); + }, + deleteMultipleItems() { + if (this.itemsToBeDeleted.length === 1) { + this.setModalDescription(this.itemsToBeDeleted[0]); + } else if (this.itemsToBeDeleted.length > 1) { + this.setModalDescription(); + } + + this.$refs.deleteModal.$refs.modal.$once('ok', () => { + this.removeModalEvents(); + this.handleMultipleDelete(); + }); + }, + handleSingleDelete(itemToDelete) { + this.deleteItem(itemToDelete) .then(() => this.fetchList({ repo: this.repo })) .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY)); }, + handleMultipleDelete() { + const { itemsToBeDeleted } = this; + this.itemsToBeDeleted = []; + + if (this.bulkDeletePath) { + this.multiDeleteItems({ + path: this.bulkDeletePath, + items: itemsToBeDeleted.map(x => this.repo.list[x].tag), + }) + .then(() => this.fetchList({ repo: this.repo })) + .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY)); + } else { + this.showError(errorMessagesTypes.DELETE_REGISTRY); + } + }, onPageChange(pageNumber) { this.fetchList({ repo: this.repo, page: pageNumber }).catch(() => this.showError(errorMessagesTypes.FETCH_REGISTRY), @@ -65,6 +138,35 @@ export default { showError(message) { createFlash(errorMessages[message]); }, + onSelectAllChange() { + if (this.selectAllChecked) { + this.deselectAll(); + } else { + this.selectAll(); + } + }, + selectAll() { + this.itemsToBeDeleted = this.repo.list.map((x, index) => index); + this.selectAllChecked = true; + }, + deselectAll() { + this.itemsToBeDeleted = []; + this.selectAllChecked = false; + }, + updateItemsToBeDeleted(index) { + const delIndex = this.itemsToBeDeleted.findIndex(x => x === index); + + if (delIndex > -1) { + this.itemsToBeDeleted.splice(delIndex, 1); + this.selectAllChecked = false; + } else { + this.itemsToBeDeleted.push(index); + + if (this.itemsToBeDeleted.length === this.repo.list.length) { + this.selectAllChecked = true; + } + } + }, }, }; </script> @@ -73,15 +175,44 @@ export default { <table class="table tags"> <thead> <tr> + <th> + <gl-form-checkbox + v-if="repo.canDelete" + class="js-select-all-checkbox" + :checked="selectAllChecked" + @change="onSelectAllChange" + /> + </th> <th>{{ s__('ContainerRegistry|Tag') }}</th> <th>{{ s__('ContainerRegistry|Tag ID') }}</th> <th>{{ s__('ContainerRegistry|Size') }}</th> <th>{{ s__('ContainerRegistry|Last Updated') }}</th> - <th></th> + <th> + <gl-button + v-if="repo.canDelete" + v-gl-tooltip + v-gl-modal="modalId" + :disabled="!itemsToBeDeleted || itemsToBeDeleted.length === 0" + class="js-delete-registry float-right" + variant="danger" + :title="s__('ContainerRegistry|Remove selected images')" + :aria-label="s__('ContainerRegistry|Remove selected images')" + @click="deleteMultipleItems()" + ><icon name="remove" + /></gl-button> + </th> </tr> </thead> <tbody> - <tr v-for="item in repo.list" :key="item.tag"> + <tr v-for="(item, index) in repo.list" :key="item.tag" class="registry-image-row"> + <td class="check"> + <gl-form-checkbox + v-if="item.canDelete" + class="js-select-checkbox" + :checked="itemsToBeDeleted && itemsToBeDeleted.includes(index)" + @change="updateItemsToBeDeleted(index)" + /> + </td> <td class="monospace"> {{ item.tag }} <clipboard-button @@ -110,16 +241,15 @@ export default { </span> </td> - <td class="content"> + <td class="content action-buttons"> <gl-button v-if="item.canDelete" - v-gl-tooltip - v-gl-modal="'confirm-image-deletion-modal'" + v-gl-modal="modalId" :title="s__('ContainerRegistry|Remove image')" :aria-label="s__('ContainerRegistry|Remove image')" variant="danger" - class="js-delete-registry d-none d-sm-block float-right" - @click="setItemToBeDeleted(item)" + class="js-delete-registry-row float-right btn-inverted btn-border-color btn-icon" + @click="deleteSingleItem(index)" > <icon name="remove" /> </gl-button> @@ -134,23 +264,10 @@ export default { :page-info="repo.pagination" /> - <gl-modal - modal-id="confirm-image-deletion-modal" - ok-variant="danger" - @ok="handleDeleteRegistry" - > - <template v-slot:modal-title>{{ s__('ContainerRegistry|Remove image') }}</template> - <template v-slot:modal-ok>{{ s__('ContainerRegistry|Remove image and tags') }}</template> - <p - v-html=" - sprintf( - s__( - 'ContainerRegistry|You are about to delete the image <b>%{title}</b>. This will delete the image and all tags pointing to this image.', - ), - { title: repo.name }, - ) - " - ></p> + <gl-modal ref="deleteModal" :modal-id="modalId" ok-variant="danger"> + <template v-slot:modal-title>{{ modalTitle }}</template> + <template v-slot:modal-ok>{{ s__('ContainerRegistry|Remove image(s) and tags') }}</template> + <p v-html="modalDescription"></p> </gl-modal> </div> </template> diff --git a/app/assets/javascripts/registry/index.js b/app/assets/javascripts/registry/index.js index 025afefe7f0..d8daec29fda 100644 --- a/app/assets/javascripts/registry/index.js +++ b/app/assets/javascripts/registry/index.js @@ -14,12 +14,22 @@ export default () => const { dataset } = document.querySelector(this.$options.el); return { endpoint: dataset.endpoint, + characterError: Boolean(dataset.characterError), + helpPagePath: dataset.helpPagePath, + noContainersImage: dataset.noContainersImage, + containersErrorImage: dataset.containersErrorImage, + repositoryUrl: dataset.repositoryUrl, }; }, render(createElement) { return createElement('registry-app', { props: { endpoint: this.endpoint, + characterError: this.characterError, + helpPagePath: this.helpPagePath, + noContainersImage: this.noContainersImage, + containersErrorImage: this.containersErrorImage, + repositoryUrl: this.repositoryUrl, }, }); }, diff --git a/app/assets/javascripts/registry/stores/actions.js b/app/assets/javascripts/registry/stores/actions.js index 0f5e9cc73a0..a2e0130e79e 100644 --- a/app/assets/javascripts/registry/stores/actions.js +++ b/app/assets/javascripts/registry/stores/actions.js @@ -36,6 +36,8 @@ export const fetchList = ({ commit }, { repo, page }) => { }; export const deleteItem = (_, item) => axios.delete(item.destroyPath); +export const multiDeleteItems = (_, { path, items }) => + axios.delete(path, { params: { ids: items } }); 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/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue index 6d908524da9..f0112a5a623 100644 --- a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue +++ b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue @@ -65,7 +65,7 @@ export default { <template> <div v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)"> - <div id="merge-requests" class="card-slim mt-3"> + <div id="merge-requests" class="card card-slim mt-3"> <div class="card-header"> <div class="card-title mt-0 mb-0 h5 merge-requests-title"> <span class="mr-1"> diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue index f510b905a2e..7580c2d0ad0 100644 --- a/app/assets/javascripts/releases/components/release_block.vue +++ b/app/assets/javascripts/releases/components/release_block.vue @@ -1,10 +1,11 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import _ from 'underscore'; import { GlTooltipDirective, GlLink, GlBadge } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; -import { sprintf } from '../../locale'; +import { __, sprintf } from '../../locale'; export default { name: 'ReleaseBlock', @@ -27,13 +28,13 @@ export default { }, computed: { releasedTimeAgo() { - return sprintf('released %{time}', { - time: this.timeFormated(this.release.created_at), + return sprintf(__('released %{time}'), { + time: this.timeFormated(this.release.released_at), }); }, userImageAltDescription() { return this.author && this.author.username - ? sprintf("%{username}'s avatar", { username: this.author.username }) + ? sprintf(__("%{username}'s avatar"), { username: this.author.username }) : null; }, commit() { @@ -56,8 +57,8 @@ export default { <div class="card-body"> <h2 class="card-title mt-0"> {{ release.name }} - <gl-badge v-if="release.pre_release" variant="warning" class="align-middle">{{ - __('Pre-release') + <gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{ + __('Upcoming Release') }}</gl-badge> </h2> @@ -74,7 +75,7 @@ export default { <div class="append-right-4"> • - <span v-gl-tooltip.bottom :title="tooltipTitle(release.created_at)"> + <span v-gl-tooltip.bottom :title="tooltipTitle(release.released_at)"> {{ releasedTimeAgo }} </span> </div> diff --git a/app/assets/javascripts/reports/components/issue_status_icon.vue b/app/assets/javascripts/reports/components/issue_status_icon.vue index 04fba43b2f3..386653b9444 100644 --- a/app/assets/javascripts/reports/components/issue_status_icon.vue +++ b/app/assets/javascripts/reports/components/issue_status_icon.vue @@ -16,7 +16,7 @@ export default { statusIconSize: { type: Number, required: false, - default: 32, + default: 24, }, }, computed: { diff --git a/app/assets/javascripts/reports/components/modal.vue b/app/assets/javascripts/reports/components/modal.vue index 162421b037f..cb9c1642608 100644 --- a/app/assets/javascripts/reports/components/modal.vue +++ b/app/assets/javascripts/reports/components/modal.vue @@ -1,4 +1,5 @@ <script> +// import { sprintf, __ } from '~/locale'; import Modal from '~/vue_shared/components/gl_modal.vue'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; import CodeBlock from '~/vue_shared/components/code_block.vue'; @@ -47,9 +48,9 @@ export default { </a> </template> - <template v-else-if="field.type === $options.fieldTypes.miliseconds"> - {{ field.value }} ms - </template> + <template v-else-if="field.type === $options.fieldTypes.miliseconds">{{ + sprintf(__('%{value} ms'), { value: field.value }) + }}</template> <template v-else-if="field.type === $options.fieldTypes.text"> {{ field.value }} diff --git a/app/assets/javascripts/reports/components/report_item.vue b/app/assets/javascripts/reports/components/report_item.vue index 2be9c37b00a..f3f7d2648a8 100644 --- a/app/assets/javascripts/reports/components/report_item.vue +++ b/app/assets/javascripts/reports/components/report_item.vue @@ -27,7 +27,7 @@ export default { statusIconSize: { type: Number, required: false, - default: 32, + default: 24, }, isNew: { type: Boolean, @@ -43,12 +43,15 @@ export default { }; </script> <template> - <li :class="{ 'is-dismissed': issue.isDismissed }" class="report-block-list-issue"> + <li + :class="{ 'is-dismissed': issue.isDismissed }" + class="report-block-list-issue align-items-center" + > <issue-status-icon v-if="showReportSectionStatusIcon" :status="status" :status-icon-size="statusIconSize" - class="append-right-5" + class="append-right-default" /> <component :is="component" v-if="component" :issue="issue" :status="status" :is-new="isNew" /> diff --git a/app/assets/javascripts/reports/components/report_link.vue b/app/assets/javascripts/reports/components/report_link.vue index 052bc53d610..e32e1ac49ca 100644 --- a/app/assets/javascripts/reports/components/report_link.vue +++ b/app/assets/javascripts/reports/components/report_link.vue @@ -1,4 +1,5 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ export default { name: 'ReportIssueLink', props: { diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue index 3d576caaf8f..24612c8681a 100644 --- a/app/assets/javascripts/reports/components/report_section.vue +++ b/app/assets/javascripts/reports/components/report_section.vue @@ -165,7 +165,7 @@ export default { <template> <section class="media-section"> <div class="media"> - <status-icon :status="statusIconName" /> + <status-icon :status="statusIconName" :size="24" /> <div class="media-body d-flex flex-align-self-center"> <span class="js-code-text code-text"> {{ headerText }} diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/reports/components/summary_row.vue index 97a68531d29..aba798e63d0 100644 --- a/app/assets/javascripts/reports/components/summary_row.vue +++ b/app/assets/javascripts/reports/components/summary_row.vue @@ -44,10 +44,14 @@ export default { }; </script> <template> - <div class="report-block-list-issue report-block-list-issue-parent"> - <div class="report-block-list-icon append-right-10 prepend-left-5"> - <gl-loading-icon v-if="statusIcon === 'loading'" css-class="report-block-list-loading-icon" /> - <ci-icon v-else :status="iconStatus" /> + <div class="report-block-list-issue report-block-list-issue-parent align-items-center"> + <div class="report-block-list-icon append-right-default"> + <gl-loading-icon + v-if="statusIcon === 'loading'" + css-class="report-block-list-loading-icon" + size="md" + /> + <ci-icon v-else :status="iconStatus" :size="24" /> </div> <div class="report-block-list-issue-description"> diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index 67963dc1923..afb58a60155 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -1,12 +1,41 @@ <script> +import { GlDropdown, GlDropdownDivider, GlDropdownHeader, GlDropdownItem } from '@gitlab/ui'; +import { __ } from '../../locale'; +import Icon from '../../vue_shared/components/icon.vue'; import getRefMixin from '../mixins/get_ref'; import getProjectShortPath from '../queries/getProjectShortPath.query.graphql'; +import getProjectPath from '../queries/getProjectPath.query.graphql'; +import getPermissions from '../queries/getPermissions.query.graphql'; + +const ROW_TYPES = { + header: 'header', + divider: 'divider', +}; export default { + components: { + GlDropdown, + GlDropdownDivider, + GlDropdownHeader, + GlDropdownItem, + Icon, + }, apollo: { projectShortPath: { query: getProjectShortPath, }, + projectPath: { + query: getProjectPath, + }, + userPermissions: { + query: getPermissions, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update: data => data.project.userPermissions, + }, }, mixins: [getRefMixin], props: { @@ -15,10 +44,52 @@ export default { required: false, default: '/', }, + canCollaborate: { + type: Boolean, + required: false, + default: false, + }, + canEditTree: { + type: Boolean, + required: false, + default: false, + }, + newBranchPath: { + type: String, + required: false, + default: null, + }, + newTagPath: { + type: String, + required: false, + default: null, + }, + newBlobPath: { + type: String, + required: false, + default: null, + }, + forkNewBlobPath: { + type: String, + required: false, + default: null, + }, + forkNewDirectoryPath: { + type: String, + required: false, + default: null, + }, + forkUploadBlobPath: { + type: String, + required: false, + default: null, + }, }, data() { return { projectShortPath: '', + projectPath: '', + userPermissions: {}, }; }, computed: { @@ -39,11 +110,112 @@ export default { [{ name: this.projectShortPath, path: '/', to: `/tree/${this.ref}/` }], ); }, + canCreateMrFromFork() { + return this.userPermissions.forkProject && this.userPermissions.createMergeRequestIn; + }, + dropdownItems() { + const items = []; + + if (this.canEditTree) { + items.push( + { + type: ROW_TYPES.header, + text: __('This directory'), + }, + { + attrs: { + href: this.newBlobPath, + class: 'qa-new-file-option', + }, + text: __('New file'), + }, + { + attrs: { + href: '#modal-upload-blob', + 'data-target': '#modal-upload-blob', + 'data-toggle': 'modal', + }, + text: __('Upload file'), + }, + { + attrs: { + href: '#modal-create-new-dir', + 'data-target': '#modal-create-new-dir', + 'data-toggle': 'modal', + }, + text: __('New directory'), + }, + ); + } else if (this.canCreateMrFromFork) { + items.push( + { + attrs: { + href: this.forkNewBlobPath, + 'data-method': 'post', + }, + text: __('New file'), + }, + { + attrs: { + href: this.forkUploadBlobPath, + 'data-method': 'post', + }, + text: __('Upload file'), + }, + { + attrs: { + href: this.forkNewDirectoryPath, + 'data-method': 'post', + }, + text: __('New directory'), + }, + ); + } + + if (this.userPermissions.pushCode) { + items.push( + { + type: ROW_TYPES.divider, + }, + { + type: ROW_TYPES.header, + text: __('This repository'), + }, + { + attrs: { + href: this.newBranchPath, + }, + text: __('New branch'), + }, + { + attrs: { + href: this.newTagPath, + }, + text: __('New tag'), + }, + ); + } + + return items; + }, + renderAddToTreeDropdown() { + return this.canCollaborate || this.canCreateMrFromFork; + }, }, methods: { isLast(i) { return i === this.pathLinks.length - 1; }, + getComponent(type) { + switch (type) { + case ROW_TYPES.divider: + return 'gl-dropdown-divider'; + case ROW_TYPES.header: + return 'gl-dropdown-header'; + default: + return 'gl-dropdown-item'; + } + }, }, }; </script> @@ -56,6 +228,20 @@ export default { {{ link.name }} </router-link> </li> + <li v-if="renderAddToTreeDropdown" class="breadcrumb-item"> + <gl-dropdown toggle-class="add-to-tree qa-add-to-tree ml-1"> + <template slot="button-content"> + <span class="sr-only">{{ __('Add to tree') }}</span> + <icon name="plus" :size="16" class="float-left" /> + <icon name="arrow-down" :size="16" class="float-left" /> + </template> + <template v-for="(item, i) in dropdownItems"> + <component :is="getComponent(item.type)" :key="i" v-bind="item.attrs"> + {{ item.text }} + </component> + </template> + </gl-dropdown> + </li> </ol> </nav> </template> diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 26493556063..e2060d4aeec 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -1,4 +1,5 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { GlTooltipDirective, GlLink, GlButton, GlLoadingIcon } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; import Icon from '../../vue_shared/components/icon.vue'; diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 1e66ccbfa29..610c7e8d99e 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -76,7 +76,7 @@ export default { variables: { projectPath: this.projectPath, ref: this.ref, - path: this.path, + path: this.path || '/', nextPageCursor: this.nextPageCursor, pageSize: PAGE_SIZE, }, @@ -137,6 +137,7 @@ export default { :path="entry.flatPath" :type="entry.type" :url="entry.webUrl" + :submodule-tree-url="entry.treeUrl" :lfs-oid="entry.lfsOid" /> </template> diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index c31e7fa71a2..171841178a3 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -62,6 +62,11 @@ export default { required: false, default: null, }, + submoduleTreeUrl: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -110,11 +115,10 @@ export default { <component :is="linkComponent" :to="routerLinkTo" :href="url" class="str-truncated"> {{ fullPath }} </component> - <gl-badge v-if="lfsOid" variant="default" class="label-lfs ml-1"> - LFS - </gl-badge> + <!-- eslint-disable-next-line @gitlab/vue-i18n/no-bare-strings --> + <gl-badge v-if="lfsOid" variant="default" class="label-lfs ml-1">LFS</gl-badge> <template v-if="isSubmodule"> - @ <gl-link href="#" class="commit-sha">{{ shortSha }}</gl-link> + @ <gl-link :href="submoduleTreeUrl" class="commit-sha">{{ shortSha }}</gl-link> </template> </td> <td class="d-none d-sm-table-cell tree-commit"> diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index ea051eaa414..f9727960040 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -5,6 +5,7 @@ import Breadcrumbs from './components/breadcrumbs.vue'; import LastCommit from './components/last_commit.vue'; import apolloProvider from './graphql'; import { setTitle } from './utils/title'; +import { parseBoolean } from '../lib/utils/common_utils'; export default function setupVueRepositoryList() { const el = document.getElementById('js-tree-list'); @@ -36,19 +37,42 @@ export default function setupVueRepositoryList() { .forEach(elem => elem.classList.toggle('hidden', !isRoot)); }); - // eslint-disable-next-line no-new - new Vue({ - el: document.getElementById('js-repo-breadcrumb'), - router, - apolloProvider, - render(h) { - return h(Breadcrumbs, { - props: { - currentPath: this.$route.params.pathMatch, - }, - }); - }, - }); + const breadcrumbEl = document.getElementById('js-repo-breadcrumb'); + + if (breadcrumbEl) { + const { + canCollaborate, + canEditTree, + newBranchPath, + newTagPath, + newBlobPath, + forkNewBlobPath, + forkNewDirectoryPath, + forkUploadBlobPath, + } = breadcrumbEl.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el: breadcrumbEl, + router, + apolloProvider, + render(h) { + return h(Breadcrumbs, { + props: { + currentPath: this.$route.params.pathMatch, + canCollaborate: parseBoolean(canCollaborate), + canEditTree: parseBoolean(canEditTree), + newBranchPath, + newTagPath, + newBlobPath, + forkNewBlobPath, + forkNewDirectoryPath, + forkUploadBlobPath, + }, + }); + }, + }); + } // eslint-disable-next-line no-new new Vue({ diff --git a/app/assets/javascripts/repository/queries/getFiles.query.graphql b/app/assets/javascripts/repository/queries/getFiles.query.graphql index 4c24fc4087f..c4814f8e63a 100644 --- a/app/assets/javascripts/repository/queries/getFiles.query.graphql +++ b/app/assets/javascripts/repository/queries/getFiles.query.graphql @@ -1,3 +1,5 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" + fragment TreeEntry on Entry { id name @@ -5,11 +7,6 @@ fragment TreeEntry on Entry { type } -fragment PageInfo on PageInfo { - hasNextPage - endCursor -} - query getFiles( $projectPath: ID! $path: String @@ -35,6 +32,8 @@ query getFiles( edges { node { ...TreeEntry + webUrl + treeUrl } } pageInfo { diff --git a/app/assets/javascripts/repository/queries/getPermissions.query.graphql b/app/assets/javascripts/repository/queries/getPermissions.query.graphql new file mode 100644 index 00000000000..092fa44e2d0 --- /dev/null +++ b/app/assets/javascripts/repository/queries/getPermissions.query.graphql @@ -0,0 +1,9 @@ +query getPermissions($projectPath: ID!) { + project(fullPath: $projectPath) { + userPermissions { + pushCode + forkProject + createMergeRequestIn + } + } +} diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 930c0d5e958..40a2158de78 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -101,10 +101,12 @@ Sidebar.prototype.toggleTodo = function(e) { this.todoUpdateDone(data); }) .catch(() => - flash(sprintf(__('There was an error %{message} todo.')), { - message: - ajaxType === 'post' ? s__('RightSidebar|adding a') : s__('RightSidebar|deleting the'), - }), + flash( + sprintf(__('There was an error %{message} todo.'), { + message: + ajaxType === 'post' ? s__('RightSidebar|adding a') : s__('RightSidebar|deleting the'), + }), + ), ); }; diff --git a/app/assets/javascripts/serverless/components/area.vue b/app/assets/javascripts/serverless/components/area.vue index 32c9d6eccb8..a1a8cd3acbd 100644 --- a/app/assets/javascripts/serverless/components/area.vue +++ b/app/assets/javascripts/serverless/components/area.vue @@ -4,6 +4,7 @@ import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; import dateFormat from 'dateformat'; import { X_INTERVAL } from '../constants'; import { validateGraphData } from '../utils'; +import { __ } from '~/locale'; let debouncedResize; @@ -42,7 +43,7 @@ export default { }, generateSeries() { return { - name: 'Invocations', + name: __('Invocations'), type: 'line', data: this.chartData.requests.map(data => [data.time, data.value]), symbolSize: 0, @@ -124,7 +125,9 @@ export default { <div class="prometheus-graph"> <div class="prometheus-graph-header"> <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5> - <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div> + <div ref="graphWidgets" class="prometheus-graph-widgets"> + <slot></slot> + </div> </div> <gl-area-chart ref="areaChart" @@ -135,12 +138,8 @@ export default { :width="width" :include-legend-avg-max="false" > - <template slot="tooltipTitle"> - {{ tooltipPopoverTitle }} - </template> - <template slot="tooltipContent"> - {{ tooltipPopoverContent }} - </template> + <template slot="tooltipTitle">{{ tooltipPopoverTitle }}</template> + <template slot="tooltipContent">{{ tooltipPopoverContent }}</template> </gl-area-chart> </div> </template> diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue index b8906cfca4e..d542dad8119 100644 --- a/app/assets/javascripts/serverless/components/function_details.vue +++ b/app/assets/javascripts/serverless/components/function_details.vue @@ -89,7 +89,9 @@ export default { }} </p> </div> - <div v-else><p>No pods loaded at this time.</p></div> + <div v-else> + <p>{{ s__('ServerlessDetails|No pods loaded at this time.') }}</p> + </div> <area-chart v-if="hasPrometheusData" :graph-data="graphData" :container-width="elWidth" /> <missing-prometheus diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index 94341050b86..9e66869515c 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -1,4 +1,5 @@ <script> +import { sprintf, s__ } from '~/locale'; import { mapState, mapActions, mapGetters } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; import FunctionRow from './function_row.vue'; @@ -37,6 +38,28 @@ export default { isInstalled() { return this.installed === true; }, + noServerlessConfigFile() { + return sprintf( + s__( + 'Serverless|Your repository does not have a corresponding %{startTag}serverless.yml%{endTag} file.', + ), + { startTag: '<code>', endTag: '</code>' }, + ); + }, + noGitlabYamlConfigured() { + return sprintf( + s__('Serverless|Your %{startTag}.gitlab-ci.yml%{endTag} file is not properly configured.'), + { startTag: '<code>', endTag: '</code>' }, + ); + }, + mismatchedServerlessFunctions() { + return sprintf( + s__( + "Serverless|The functions listed in the %{startTag}serverless.yml%{endTag} file don't match the namespace of your cluster.", + ), + { startTag: '<code>', endTag: '</code>' }, + ); + }, }, created() { this.fetchFunctions({ @@ -82,25 +105,29 @@ export default { <h4 class="state-title text-center">{{ s__('Serverless|No functions available') }}</h4> <p class="state-description"> {{ - s__(`Serverless|There is currently no function data available from Knative. - This could be for a variety of reasons including:`) + s__( + 'Serverless|There is currently no function data available from Knative. This could be for a variety of reasons including:', + ) }} </p> <ul> - <li>Your repository does not have a corresponding <code>serverless.yml</code> file.</li> - <li>Your <code>.gitlab-ci.yml</code> file is not properly configured.</li> <li> - The functions listed in the <code>serverless.yml</code> file don't match the namespace - of your cluster. + {{ noServerlessConfigFile }} + </li> + <li> + {{ noGitlabYamlConfigured }} + </li> + <li> + {{ mismatchedServerlessFunctions }} </li> - <li>The deploy job has not finished.</li> + <li>{{ s__('Serverless|The deploy job has not finished.') }}</li> </ul> <p> {{ - s__(`Serverless|If you believe none of these apply, please check - back later as the function data may be in the process of becoming - available.`) + s__( + 'Serverless|If you believe none of these apply, please check back later as the function data may be in the process of becoming available.', + ) }} </p> <div class="text-center"> diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue new file mode 100644 index 00000000000..71a1fc31315 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue @@ -0,0 +1,48 @@ +<script> +import { __, sprintf } from '~/locale'; + +export default { + props: { + user: { + type: Object, + required: true, + }, + imgSize: { + type: Number, + required: true, + }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + }, + computed: { + assigneeAlt() { + return sprintf(__("%{userName}'s avatar"), { userName: this.user.name }); + }, + avatarUrl() { + return this.user.avatar || this.user.avatar_url || gon.default_avatar_url; + }, + isMergeRequest() { + return this.issuableType === 'merge_request'; + }, + hasMergeIcon() { + return this.isMergeRequest && !this.user.can_merge; + }, + }, +}; +</script> + +<template> + <span class="position-relative"> + <img + :alt="assigneeAlt" + :src="avatarUrl" + :width="imgSize" + :class="`s${imgSize}`" + class="avatar avatar-inline m-0" + /> + <i v-if="hasMergeIcon" aria-hidden="true" class="fa fa-exclamation-triangle merge-icon"></i> + </span> +</template> diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue new file mode 100644 index 00000000000..6633a63d046 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue @@ -0,0 +1,83 @@ +<script> +import { __, sprintf } from '~/locale'; +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { joinPaths } from '~/lib/utils/url_utility'; +import AssigneeAvatar from './assignee_avatar.vue'; + +export default { + components: { + AssigneeAvatar, + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + user: { + type: Object, + required: true, + }, + rootPath: { + type: String, + required: true, + }, + tooltipPlacement: { + type: String, + default: 'bottom', + required: false, + }, + tooltipHasName: { + type: Boolean, + default: true, + required: false, + }, + issuableType: { + type: String, + default: 'issue', + required: false, + }, + }, + computed: { + cannotMerge() { + return this.issuableType === 'merge_request' && !this.user.can_merge; + }, + tooltipTitle() { + if (this.cannotMerge && this.tooltipHasName) { + return sprintf(__('%{userName} (cannot merge)'), { userName: this.user.name }); + } else if (this.cannotMerge) { + return __('Cannot merge'); + } else if (this.tooltipHasName) { + return this.user.name; + } + + return ''; + }, + tooltipOption() { + return { + container: 'body', + placement: this.tooltipPlacement, + boundary: 'viewport', + }; + }, + assigneeUrl() { + return joinPaths(`${this.rootPath}`, `${this.user.username}`); + }, + }, +}; +</script> + +<template> + <!-- must be `d-inline-block` or parent flex-basis causes width issues --> + <gl-link + v-gl-tooltip="tooltipOption" + :href="assigneeUrl" + :title="tooltipTitle" + class="d-inline-block" + > + <!-- use d-flex so that slot can be appropriately styled --> + <span class="d-flex"> + <assignee-avatar :user="user" :img-size="32" :issuable-type="issuableType" /> + <slot :user="user"></slot> + </span> + </gl-link> +</template> diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue index 0ad2b3a73a2..63b93a80ead 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue @@ -1,4 +1,7 @@ <script> +import { n__ } from '~/locale'; +import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar'; + export default { name: 'AssigneeTitle', props: { @@ -24,7 +27,12 @@ export default { computed: { assigneeTitle() { const assignees = this.numberOfAssignees; - return assignees > 1 ? `${assignees} Assignees` : 'Assignee'; + return n__('Assignee', `%d Assignees`, assignees); + }, + }, + methods: { + trackEdit() { + trackEvent('click_edit_button', 'assignee'); }, }, }; @@ -32,18 +40,23 @@ export default { <template> <div class="title hide-collapsed"> {{ assigneeTitle }} - <i v-if="loading" aria-hidden="true" class="fa fa-spinner fa-spin block-loading"> </i> - <a v-if="editable" class="js-sidebar-dropdown-toggle edit-link float-right" href="#"> + <i v-if="loading" aria-hidden="true" class="fa fa-spinner fa-spin block-loading"></i> + <a + v-if="editable" + class="js-sidebar-dropdown-toggle edit-link float-right" + href="#" + @click.prevent="trackEdit" + > {{ __('Edit') }} </a> <a v-if="showToggle" - aria-label="Toggle sidebar" + :aria-label="__('Toggle sidebar')" class="gutter-toggle float-right js-sidebar-toggle" href="#" role="button" > - <i aria-hidden="true" data-hidden="true" class="fa fa-angle-double-right"> </i> + <i aria-hidden="true" data-hidden="true" class="fa fa-angle-double-right"></i> </a> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue index 0074d7099dc..d9739e8d197 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue @@ -1,11 +1,14 @@ <script> -import { __ } from '~/locale'; -import tooltip from '~/vue_shared/directives/tooltip'; +import CollapsedAssigneeList from '../assignees/collapsed_assignee_list.vue'; +import UncollapsedAssigneeList from '../assignees/uncollapsed_assignee_list.vue'; export default { + // name: 'Assignees' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings name: 'Assignees', - directives: { - tooltip, + components: { + CollapsedAssigneeList, + UncollapsedAssigneeList, }, props: { rootPath: { @@ -22,222 +25,52 @@ export default { }, issuableType: { type: String, - require: true, + required: false, default: 'issue', }, }, - data() { - return { - defaultRenderCount: 5, - defaultMaxCounter: 99, - showLess: true, - }; - }, computed: { - firstUser() { - return this.users[0]; - }, - hasMoreThanTwoAssignees() { - return this.users.length > 2; - }, - hasMoreThanOneAssignee() { - return this.users.length > 1; - }, - hasAssignees() { - return this.users.length > 0; - }, hasNoUsers() { return !this.users.length; }, - hasOneUser() { - return this.users.length === 1; - }, - renderShowMoreSection() { - return this.users.length > this.defaultRenderCount; - }, - numberOfHiddenAssignees() { - return this.users.length - this.defaultRenderCount; - }, - isHiddenAssignees() { - return this.numberOfHiddenAssignees > 0; - }, - hiddenAssigneesLabel() { - return `+ ${this.numberOfHiddenAssignees} more`; - }, - collapsedTooltipTitle() { - const maxRender = Math.min(this.defaultRenderCount, this.users.length); - const renderUsers = this.users.slice(0, maxRender); - const names = renderUsers.map(u => u.name); - - if (this.users.length > maxRender) { - names.push(`+ ${this.users.length - maxRender} more`); - } - - if (!this.users.length) { - const emptyTooltipLabel = __('Assignee(s)'); - names.push(emptyTooltipLabel); - } - - return names.join(', '); - }, - sidebarAvatarCounter() { - let counter = `+${this.users.length - 1}`; - - if (this.users.length > this.defaultMaxCounter) { - counter = `${this.defaultMaxCounter}+`; - } + sortedAssigness() { + const canMergeUsers = this.users.filter(user => user.can_merge); + const canNotMergeUsers = this.users.filter(user => !user.can_merge); - return counter; - }, - mergeNotAllowedTooltipMessage() { - const assigneesCount = this.users.length; - - if (this.issuableType !== 'merge_request' || assigneesCount === 0) { - return null; - } - - const cannotMergeCount = this.users.filter(u => u.can_merge === false).length; - const canMergeCount = assigneesCount - cannotMergeCount; - - if (canMergeCount === assigneesCount) { - // Everyone can merge - return null; - } else if (cannotMergeCount === assigneesCount && assigneesCount > 1) { - return 'No one can merge'; - } else if (assigneesCount === 1) { - return 'Cannot merge'; - } - - return `${canMergeCount}/${assigneesCount} can merge`; + return [...canMergeUsers, ...canNotMergeUsers]; }, }, methods: { assignSelf() { this.$emit('assign-self'); }, - toggleShowLess() { - this.showLess = !this.showLess; - }, - renderAssignee(index) { - return !this.showLess || (index < this.defaultRenderCount && this.showLess); - }, - avatarUrl(user) { - return user.avatar || user.avatar_url || gon.default_avatar_url; - }, - assigneeUrl(user) { - return `${this.rootPath}${user.username}`; - }, - assigneeAlt(user) { - return `${user.name}'s avatar`; - }, - assigneeUsername(user) { - return `@${user.username}`; - }, - shouldRenderCollapsedAssignee(index) { - const firstTwo = this.users.length <= 2 && index <= 2; - - return index === 0 || firstTwo; - }, }, }; </script> <template> <div> - <div - v-tooltip - :class="{ 'multiple-users': hasMoreThanOneAssignee }" - :title="collapsedTooltipTitle" - class="sidebar-collapsed-icon sidebar-collapsed-user" - data-container="body" - data-placement="left" - data-boundary="viewport" - > - <i v-if="hasNoUsers" aria-label="None" class="fa fa-user"> </i> - <button - v-for="(user, index) in users" - v-if="shouldRenderCollapsedAssignee(index)" - :key="user.id" - type="button" - class="btn-link" - > - <img - :alt="assigneeAlt(user)" - :src="avatarUrl(user)" - width="24" - class="avatar avatar-inline s24" - /> - <span class="author"> {{ user.name }} </span> - </button> - <button v-if="hasMoreThanTwoAssignees" class="btn-link" type="button"> - <span class="avatar-counter sidebar-avatar-counter"> {{ sidebarAvatarCounter }} </span> - </button> - </div> + <collapsed-assignee-list :users="sortedAssigness" :issuable-type="issuableType" /> + <div class="value hide-collapsed"> - <span - v-if="mergeNotAllowedTooltipMessage" - v-tooltip - :title="mergeNotAllowedTooltipMessage" - data-placement="left" - class="float-right cannot-be-merged" - > - <i aria-hidden="true" data-hidden="true" class="fa fa-exclamation-triangle"></i> - </span> <template v-if="hasNoUsers"> <span class="assign-yourself no-value qa-assign-yourself"> - None + {{ __('None') }} <template v-if="editable"> - - <button type="button" class="btn-link" @click="assignSelf">assign yourself</button> + - + <button type="button" class="btn-link" @click="assignSelf"> + {{ __('assign yourself') }} + </button> </template> </span> </template> - <template v-else-if="hasOneUser"> - <a :href="assigneeUrl(firstUser)" class="author-link bold"> - <img - :alt="assigneeAlt(firstUser)" - :src="avatarUrl(firstUser)" - width="32" - class="avatar avatar-inline s32" - /> - <span class="author"> {{ firstUser.name }} </span> - <span class="username"> {{ assigneeUsername(firstUser) }} </span> - </a> - </template> - <template v-else> - <div class="user-list"> - <div - v-for="(user, index) in users" - v-if="renderAssignee(index)" - :key="user.id" - class="user-item" - > - <a - :href="assigneeUrl(user)" - :data-title="user.name" - class="user-link has-tooltip" - data-container="body" - data-placement="bottom" - > - <img - :alt="assigneeAlt(user)" - :src="avatarUrl(user)" - width="32" - class="avatar avatar-inline s32" - /> - </a> - </div> - </div> - <div v-if="renderShowMoreSection" class="user-list-more"> - <button type="button" class="btn-link" @click="toggleShowLess"> - <template v-if="showLess"> - {{ hiddenAssigneesLabel }} - </template> - <template v-else> - - show less - </template> - </button> - </div> - </template> + + <uncollapsed-assignee-list + v-else + :users="sortedAssigness" + :root-path="rootPath" + :issuable-type="issuableType" + /> </div> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue new file mode 100644 index 00000000000..2f654409561 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue @@ -0,0 +1,27 @@ +<script> +import AssigneeAvatar from './assignee_avatar.vue'; + +export default { + components: { + AssigneeAvatar, + }, + props: { + user: { + type: Object, + required: true, + }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + }, +}; +</script> + +<template> + <button type="button" class="btn-link"> + <assignee-avatar :user="user" :img-size="24" :issuable-type="issuableType" /> + <span class="author"> {{ user.name }} </span> + </button> +</template> diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue new file mode 100644 index 00000000000..5b4a43399ca --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue @@ -0,0 +1,121 @@ +<script> +import { __, sprintf } from '~/locale'; +import { GlTooltipDirective } from '@gitlab/ui'; +import CollapsedAssignee from './collapsed_assignee.vue'; + +const DEFAULT_MAX_COUNTER = 99; +const DEFAULT_RENDER_COUNT = 5; + +export default { + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + CollapsedAssignee, + }, + props: { + users: { + type: Array, + required: true, + }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + }, + computed: { + isMergeRequest() { + return this.issuableType === 'merge_request'; + }, + hasNoUsers() { + return !this.users.length; + }, + hasMoreThanOneAssignee() { + return this.users.length > 1; + }, + hasMoreThanTwoAssignees() { + return this.users.length > 2; + }, + allAssigneesCanMerge() { + return this.users.every(user => user.can_merge); + }, + sidebarAvatarCounter() { + if (this.users.length > DEFAULT_MAX_COUNTER) { + return `${DEFAULT_MAX_COUNTER}+`; + } + + return `+${this.users.length - 1}`; + }, + collapsedUsers() { + const collapsedLength = this.hasMoreThanTwoAssignees ? 1 : this.users.length; + + return this.users.slice(0, collapsedLength); + }, + tooltipTitleMergeStatus() { + if (!this.isMergeRequest) { + return ''; + } + + const mergeLength = this.users.filter(u => u.can_merge).length; + + if (mergeLength === this.users.length) { + return ''; + } else if (mergeLength > 0) { + return sprintf(__('%{mergeLength}/%{usersLength} can merge'), { + mergeLength, + usersLength: this.users.length, + }); + } + + return this.users.length === 1 ? __('cannot merge') : __('no one can merge'); + }, + tooltipTitle() { + const maxRender = Math.min(DEFAULT_RENDER_COUNT, this.users.length); + const renderUsers = this.users.slice(0, maxRender); + const names = renderUsers.map(u => u.name); + + if (!this.users.length) { + return __('Assignee(s)'); + } + + if (this.users.length > names.length) { + names.push(sprintf(__('+ %{amount} more'), { amount: this.users.length - names.length })); + } + + const text = names.join(', '); + + return this.tooltipTitleMergeStatus ? `${text} (${this.tooltipTitleMergeStatus})` : text; + }, + + tooltipOptions() { + return { container: 'body', placement: 'left', boundary: 'viewport' }; + }, + }, +}; +</script> + +<template> + <div + v-gl-tooltip="tooltipOptions" + :class="{ 'multiple-users': hasMoreThanOneAssignee }" + :title="tooltipTitle" + class="sidebar-collapsed-icon sidebar-collapsed-user" + > + <i v-if="hasNoUsers" :aria-label="__('None')" class="fa fa-user"> </i> + <collapsed-assignee + v-for="user in collapsedUsers" + :key="user.id" + :user="user" + :issuable-type="issuableType" + /> + <button v-if="hasMoreThanTwoAssignees" class="btn-link" type="button"> + <span class="avatar-counter sidebar-avatar-counter"> {{ sidebarAvatarCounter }} </span> + <i + v-if="isMergeRequest && !allAssigneesCanMerge" + aria-hidden="true" + class="fa fa-exclamation-triangle merge-icon" + ></i> + </button> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index cfa7029b388..c6cc04a139f 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -2,8 +2,10 @@ import Flash from '~/flash'; import eventHub from '~/sidebar/event_hub'; import Store from '~/sidebar/stores/sidebar_store'; +import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import AssigneeTitle from './assignee_title.vue'; import Assignees from './assignees.vue'; +import { __ } from '~/locale'; export default { name: 'SidebarAssignees', @@ -27,7 +29,7 @@ export default { }, issuableType: { type: String, - require: true, + required: false, default: 'issue', }, }, @@ -72,9 +74,12 @@ export default { this.mediator .saveAssignees(this.field) .then(setLoadingFalse.bind(this)) + .then(() => { + refreshUserMergeRequestCounts(); + }) .catch(() => { setLoadingFalse(); - return new Flash('Error occurred when saving assignees'); + return new Flash(__('Error occurred when saving assignees')); }); }, }, diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue new file mode 100644 index 00000000000..3a4623121f4 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -0,0 +1,96 @@ +<script> +import { __, sprintf } from '~/locale'; +import AssigneeAvatarLink from './assignee_avatar_link.vue'; + +const DEFAULT_RENDER_COUNT = 5; + +export default { + components: { + AssigneeAvatarLink, + }, + props: { + users: { + type: Array, + required: true, + }, + rootPath: { + type: String, + required: true, + }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + }, + data() { + return { + showLess: true, + }; + }, + computed: { + firstUser() { + return this.users[0]; + }, + hasOneUser() { + return this.users.length === 1; + }, + hiddenAssigneesLabel() { + const { numberOfHiddenAssignees } = this; + return sprintf(__('+ %{numberOfHiddenAssignees} more'), { numberOfHiddenAssignees }); + }, + renderShowMoreSection() { + return this.users.length > DEFAULT_RENDER_COUNT; + }, + numberOfHiddenAssignees() { + return this.users.length - DEFAULT_RENDER_COUNT; + }, + uncollapsedUsers() { + const uncollapsedLength = this.showLess + ? Math.min(this.users.length, DEFAULT_RENDER_COUNT) + : this.users.length; + return this.showLess ? this.users.slice(0, uncollapsedLength) : this.users; + }, + username() { + return `@${this.firstUser.username}`; + }, + }, + methods: { + toggleShowLess() { + this.showLess = !this.showLess; + }, + }, +}; +</script> + +<template> + <assignee-avatar-link + v-if="hasOneUser" + v-slot="{ user }" + tooltip-placement="left" + :tooltip-has-name="false" + :user="firstUser" + :root-path="rootPath" + :issuable-type="issuableType" + > + <div class="ml-2"> + <span class="author"> {{ user.name }} </span> + <span class="username"> {{ username }} </span> + </div> + </assignee-avatar-link> + <div v-else> + <div class="user-list"> + <div v-for="user in uncollapsedUsers" :key="user.id" class="user-item"> + <assignee-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType" /> + </div> + </div> + <div v-if="renderShowMoreSection" class="user-list-more"> + <button type="button" class="btn-link" @click="toggleShowLess"> + <template v-if="showLess"> + {{ hiddenAssigneesLabel }} + </template> + <template v-else>{{ __('- show less') }}</template> + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue index 597b723a9d9..1c75b6148e8 100644 --- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue @@ -5,6 +5,7 @@ import tooltip from '~/vue_shared/directives/tooltip'; import Icon from '~/vue_shared/components/icon.vue'; import eventHub from '~/sidebar/event_hub'; import editForm from './edit_form.vue'; +import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar'; export default { components: { @@ -51,6 +52,11 @@ export default { toggleForm() { this.edit = !this.edit; }, + onEditClick() { + this.toggleForm(); + + trackEvent('click_edit_button', 'confidentiality'); + }, updateConfidentialAttribute(confidential) { this.service .update('issue', { confidential }) @@ -82,7 +88,7 @@ export default { v-if="isEditable" class="float-right confidential-edit" href="#" - @click.prevent="toggleForm" + @click.prevent="onEditClick" > {{ __('Edit') }} </a> diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue index 4b9bb5c7b0e..5d0e39e8195 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue @@ -1,6 +1,7 @@ <script> import $ from 'jquery'; import eventHub from '../../event_hub'; +import { __ } from '~/locale'; export default { props: { @@ -15,7 +16,7 @@ export default { }, computed: { toggleButtonText() { - return this.isConfidential ? 'Turn Off' : 'Turn On'; + return this.isConfidential ? __('Turn Off') : __('Turn On'); }, updateConfidentialBool() { return !this.isConfidential; diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue index c5cfa92f3c8..ec2a7b93a98 100644 --- a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue @@ -6,6 +6,7 @@ import issuableMixin from '~/vue_shared/mixins/issuable'; import Icon from '~/vue_shared/components/icon.vue'; import eventHub from '~/sidebar/event_hub'; import editForm from './edit_form.vue'; +import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar'; export default { components: { @@ -65,7 +66,11 @@ export default { toggleForm() { this.mediator.store.isLockDialogOpen = !this.mediator.store.isLockDialogOpen; }, + onEditClick() { + this.toggleForm(); + trackEvent('click_edit_button', 'lock_issue'); + }, updateLockedAttribute(locked) { this.mediator.service .update(this.issuableType, { @@ -109,7 +114,7 @@ export default { v-if="isEditable" class="float-right lock-edit" type="button" - @click.prevent="toggleForm" + @click.prevent="onEditClick" > {{ __('Edit') }} </button> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue index a75daca156c..1f5f19d1931 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue @@ -4,6 +4,7 @@ import icon from '~/vue_shared/components/icon.vue'; import toggleButton from '~/vue_shared/components/toggle_button.vue'; import tooltip from '~/vue_shared/directives/tooltip'; import eventHub from '../../event_hub'; +import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar'; const ICON_ON = 'notifications'; const ICON_OFF = 'notifications-off'; @@ -63,6 +64,8 @@ export default { // Component event emission. this.$emit('toggleSubscription', this.id); + + trackEvent('toggle_button', 'notifications', this.subscribed ? 0 : 1); }, onClickCollapsedIcon() { this.$emit('toggleSidebar'); @@ -73,22 +76,22 @@ export default { <template> <div> - <div class="sidebar-collapsed-icon" @click="onClickCollapsedIcon"> - <span - v-tooltip - :title="notificationTooltip" - data-container="body" - data-placement="left" - data-boundary="viewport" - > - <icon - :name="notificationIcon" - :size="16" - aria-hidden="true" - class="sidebar-item-icon is-active" - /> - </span> - </div> + <span + v-tooltip + class="sidebar-collapsed-icon" + :title="notificationTooltip" + data-container="body" + data-placement="left" + data-boundary="viewport" + @click="onClickCollapsedIcon" + > + <icon + :name="notificationIcon" + :size="16" + aria-hidden="true" + class="sidebar-item-icon is-active" + /> + </span> <span class="issuable-header-text hide-collapsed float-left"> {{ __('Notifications') }} </span> <toggle-button ref="toggleButton" diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue index 657ac837baf..24d5b14ded9 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue @@ -79,7 +79,7 @@ export default { } else if (this.showSpentOnlyState) { return `${this.timeSpent} / --`; } else if (this.showNoTimeTrackingState) { - return 'None'; + return __('None'); } return ''; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue index bc263bc36e4..06aca547183 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue @@ -2,6 +2,7 @@ import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility'; import tooltip from '../../../vue_shared/directives/tooltip'; import { GlProgressBar } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; export default { name: 'TimeTrackingComparisonPane', @@ -43,8 +44,14 @@ export default { return stringifyTime(this.parsedTimeRemaining); }, timeRemainingTooltip() { - const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:'; - return `${prefix} ${this.timeRemainingHumanReadable}`; + const { timeRemainingHumanReadable, timeRemainingMinutes } = this; + return timeRemainingMinutes < 0 + ? sprintf(s__('TimeTracking|Over by %{timeRemainingHumanReadable}'), { + timeRemainingHumanReadable, + }) + : sprintf(s__('TimeTracking|Time remaining: %{timeRemainingHumanReadable}'), { + timeRemainingHumanReadable, + }); }, /* Diff values for comparison meter */ timeRemainingMinutes() { @@ -74,12 +81,12 @@ export default { <gl-progress-bar :value="timeRemainingPercent" :variant="progressBarVariant" /> <div class="compare-display-container"> <div class="compare-display float-left"> - <span class="compare-label"> {{ s__('TimeTracking|Spent') }} </span> - <span class="compare-value spent"> {{ timeSpentHumanReadable }} </span> + <span class="compare-label">{{ s__('TimeTracking|Spent') }}</span> + <span class="compare-value spent">{{ timeSpentHumanReadable }}</span> </div> <div class="compare-display estimated float-right"> - <span class="compare-label"> {{ s__('TimeTrackingEstimated|Est') }} </span> - <span class="compare-value"> {{ timeEstimateHumanReadable }} </span> + <span class="compare-label">{{ s__('TimeTrackingEstimated|Est') }}</span> + <span class="compare-value">{{ timeEstimateHumanReadable }}</span> </div> </div> </div> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue index 7c7356e2afa..c2f30310e2e 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue @@ -1,4 +1,6 @@ <script> +import { sprintf, s__ } from '~/locale'; + export default { name: 'TimeTrackingSpentOnlyPane', props: { @@ -7,11 +9,22 @@ export default { required: true, }, }, + computed: { + timeSpent() { + return sprintf( + s__('TimeTracking|%{startTag}Spent: %{endTag}%{timeSpentHumanReadable}'), + { + startTag: '<span class="bold">', + endTag: '</span>', + timeSpentHumanReadable: this.timeSpentHumanReadable, + }, + false, + ); + }, + }, }; </script> <template> - <div class="time-tracking-spend-only-pane"> - <span class="bold">Spent:</span> {{ timeSpentHumanReadable }} - </div> + <div class="time-tracking-spend-only-pane" v-html="timeSpent"></div> </template> diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue index 57125c78cf6..e6f2fe2b5fc 100644 --- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue @@ -5,8 +5,8 @@ import { GlLoadingIcon } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; -const MARK_TEXT = __('Mark todo as done'); -const TODO_TEXT = __('Add todo'); +const MARK_TEXT = __('Mark as done'); +const TODO_TEXT = __('Add a To Do'); export default { directives: { diff --git a/app/assets/javascripts/star.js b/app/assets/javascripts/star.js index 70f89152f70..97afeecd8ac 100644 --- a/app/assets/javascripts/star.js +++ b/app/assets/javascripts/star.js @@ -18,7 +18,7 @@ export default class Star { const isStarred = $starSpan.hasClass('starred'); $this .parent() - .find('.star-count') + .find('.count') .text(data.star_count); if (isStarred) { diff --git a/app/assets/javascripts/test_utils/simulate_drag.js b/app/assets/javascripts/test_utils/simulate_drag.js index be9ebc81c6b..c9bf234fcce 100644 --- a/app/assets/javascripts/test_utils/simulate_drag.js +++ b/app/assets/javascripts/test_utils/simulate_drag.js @@ -153,7 +153,11 @@ export default function simulateDrag(options) { if (progress >= 1) { if (options.ondragend) options.ondragend(); - simulateEvent(toEl, 'mouseup'); + + if (options.performDrop) { + simulateEvent(toEl, 'mouseup'); + } + clearInterval(dragInterval); window.SIMULATE_DRAG_ACTIVE = 0; } diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js new file mode 100644 index 00000000000..a852f937eec --- /dev/null +++ b/app/assets/javascripts/tracking.js @@ -0,0 +1,73 @@ +import $ from 'jquery'; + +const extractData = (el, opts = {}) => { + const { trackEvent, trackLabel = '', trackProperty = '' } = el.dataset; + let trackValue = el.dataset.trackValue || el.value || ''; + if (el.type === 'checkbox' && !el.checked) trackValue = false; + return [ + trackEvent + (opts.suffix || ''), + { + label: trackLabel, + property: trackProperty, + value: trackValue, + }, + ]; +}; + +export default class Tracking { + static trackable() { + return !['1', 'yes'].includes( + window.doNotTrack || navigator.doNotTrack || navigator.msDoNotTrack, + ); + } + + static enabled() { + return typeof window.snowplow === 'function' && this.trackable(); + } + + static event(category = document.body.dataset.page, event = 'generic', data = {}) { + if (!this.enabled()) return false; + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings + if (!category) throw new Error('Tracking: no category provided for tracking.'); + + return window.snowplow( + 'trackStructEvent', + category, + event, + Object.assign({}, { label: '', property: '', value: '' }, data), + ); + } + + constructor(category = document.body.dataset.page) { + this.category = category; + } + + bind(container = document) { + if (!this.constructor.enabled()) return; + container.querySelectorAll(`[data-track-event]`).forEach(el => { + if (this.customHandlingFor(el)) return; + // jquery is required for select2, so we use it always + // see: https://github.com/select2/select2/issues/4686 + $(el).on('click', this.eventHandler(this.category)); + }); + } + + customHandlingFor(el) { + const classes = el.classList; + + // bootstrap dropdowns + if (classes.contains('dropdown')) { + $(el).on('show.bs.dropdown', this.eventHandler(this.category, { suffix: '_show' })); + $(el).on('hide.bs.dropdown', this.eventHandler(this.category, { suffix: '_hide' })); + return true; + } + + return false; + } + + eventHandler(category = null, opts = {}) { + return e => { + this.constructor.event(category || this.category, ...extractData(e.currentTarget, opts)); + }; + } +} diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 33cedf78331..12c939aa70f 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -62,6 +62,8 @@ function UsersSelect(currentUser, els, options = {}) { options.showCurrentUser = $dropdown.data('currentUser'); options.todoFilter = $dropdown.data('todoFilter'); options.todoStateFilter = $dropdown.data('todoStateFilter'); + options.iid = $dropdown.data('iid'); + options.issuableType = $dropdown.data('issuableType'); showNullUser = $dropdown.data('nullUser'); defaultNullUser = $dropdown.data('nullUserDefault'); showMenuAbove = $dropdown.data('showMenuAbove'); @@ -239,7 +241,7 @@ function UsersSelect(currentUser, els, options = {}) { '<% if( avatar ) { %> <a class="author-link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>', ); assigneeTemplate = _.template( - `<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> + `<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> ${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), { openingTag: '<a href="#" class="js-assign-yourself">', closingTag: '</a>', @@ -423,6 +425,8 @@ function UsersSelect(currentUser, els, options = {}) { const { $el, e, isMarking } = options; const user = options.selectedObj; + $el.tooltip('dispose'); + if ($dropdown.hasClass('js-multiselect')) { const isActive = $el.hasClass('is-active'); const previouslySelected = $dropdown @@ -570,20 +574,11 @@ function UsersSelect(currentUser, els, options = {}) { user.name, )}</a></li>`; } else { - img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />"; + // 0 margin, because it's now handled by a wrapper + img = "<img src='" + avatar + "' class='avatar avatar-inline m-0' width='32' />"; } - return ` - <li data-user-id=${user.id}> - <a href='#' class='dropdown-menu-user-link ${selected === true ? 'is-active' : ''}'> - ${img} - <strong class='dropdown-menu-user-full-name'> - ${_.escape(user.name)} - </strong> - ${username ? `<span class='dropdown-menu-user-username'>${username}</span>` : ''} - </a> - </li> - `; + return _this.renderRow(options.issuableType, user, selected, username, img); }, }); }; @@ -764,6 +759,11 @@ UsersSelect.prototype.users = function(query, options, callback) { author_id: options.authorId || null, skip_users: options.skipUsers || null, }; + + if (options.issuableType === 'merge_request') { + params.merge_request_iid = options.iid || null; + } + return axios.get(url, { params }).then(({ data }) => { callback(data); }); @@ -776,4 +776,44 @@ UsersSelect.prototype.buildUrl = function(url) { return url; }; +UsersSelect.prototype.renderRow = function(issuableType, user, selected, username, img) { + const tooltip = issuableType === 'merge_request' && !user.can_merge ? __('Cannot merge') : ''; + const tooltipClass = tooltip ? `has-tooltip` : ''; + const selectedClass = selected === true ? 'is-active' : ''; + const linkClasses = `${selectedClass} ${tooltipClass}`; + const tooltipAttributes = tooltip + ? `data-container="body" data-placement="left" data-title="${tooltip}"` + : ''; + + return ` + <li data-user-id=${user.id}> + <a href="#" class="dropdown-menu-user-link d-flex align-items-center ${linkClasses}" ${tooltipAttributes}> + ${this.renderRowAvatar(issuableType, user, img)} + <span class="d-flex flex-column overflow-hidden"> + <strong class="dropdown-menu-user-full-name"> + ${_.escape(user.name)} + </strong> + ${username ? `<span class="dropdown-menu-user-username">${username}</span>` : ''} + </span> + </a> + </li> + `; +}; + +UsersSelect.prototype.renderRowAvatar = function(issuableType, user, img) { + if (user.beforeDivider) { + return img; + } + + const mergeIcon = + issuableType === 'merge_request' && !user.can_merge + ? '<i class="fa fa-exclamation-triangle merge-icon"></i>' + : ''; + + return `<span class="position-relative mr-2"> + ${img} + ${mergeIcon} + </span>`; +}; + export default UsersSelect; diff --git a/app/assets/javascripts/visual_review_toolbar/components/comment.js b/app/assets/javascripts/visual_review_toolbar/components/comment.js deleted file mode 100644 index 04bfb5e9532..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/components/comment.js +++ /dev/null @@ -1,148 +0,0 @@ -import { BLACK, COMMENT_BOX, MUTED, LOGOUT } from './constants'; -import { clearNote, postError } from './note'; -import { - buttonClearStyles, - selectCommentBox, - selectCommentButton, - selectNote, - selectNoteContainer, -} from './utils'; - -const comment = ` - <div> - <textarea id="${COMMENT_BOX}" name="${COMMENT_BOX}" rows="3" placeholder="Enter your feedback or idea" class="gitlab-input" aria-required="true"></textarea> - <p class="gitlab-metadata-note">Additional metadata will be included: browser, OS, current page, user agent, and viewport dimensions.</p> - </div> - <div class="gitlab-button-wrapper"> - <button class="gitlab-button gitlab-button-secondary" style="${buttonClearStyles}" type="button" id="${LOGOUT}"> Log out </button> - <button class="gitlab-button gitlab-button-success" style="${buttonClearStyles}" type="button" id="gitlab-comment-button"> Send feedback </button> - </div> -`; - -const resetCommentButton = () => { - const commentButton = selectCommentButton(); - - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - commentButton.innerText = 'Send feedback'; - commentButton.classList.replace('gitlab-button-secondary', 'gitlab-button-success'); - commentButton.style.opacity = 1; -}; - -const resetCommentBox = () => { - const commentBox = selectCommentBox(); - commentBox.style.pointerEvents = 'auto'; - commentBox.style.color = BLACK; -}; - -const resetCommentText = () => { - const commentBox = selectCommentBox(); - commentBox.value = ''; -}; - -const resetComment = () => { - resetCommentButton(); - resetCommentBox(); - resetCommentText(); -}; - -const confirmAndClear = feedbackInfo => { - const commentButton = selectCommentButton(); - const currentNote = selectNote(); - const noteContainer = selectNoteContainer(); - - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - commentButton.innerText = 'Feedback sent'; - noteContainer.style.visibility = 'visible'; - currentNote.insertAdjacentHTML('beforeend', feedbackInfo); - - setTimeout(resetComment, 1000); - setTimeout(clearNote, 6000); -}; - -const setInProgressState = () => { - const commentButton = selectCommentButton(); - const commentBox = selectCommentBox(); - - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - commentButton.innerText = 'Sending feedback'; - commentButton.classList.replace('gitlab-button-success', 'gitlab-button-secondary'); - commentButton.style.opacity = 0.5; - commentBox.style.color = MUTED; - commentBox.style.pointerEvents = 'none'; -}; - -const postComment = ({ - href, - platform, - browser, - userAgent, - innerWidth, - innerHeight, - projectId, - projectPath, - mergeRequestId, - mrUrl, - token, -}) => { - // Clear any old errors - clearNote(COMMENT_BOX); - - setInProgressState(); - - const commentText = selectCommentBox().value.trim(); - - if (!commentText) { - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - postError('Your comment appears to be empty.', COMMENT_BOX); - resetCommentBox(); - resetCommentButton(); - return; - } - - const detailText = ` - \n -<details> - <summary>Metadata</summary> - Posted from ${href} | ${platform} | ${browser} | ${innerWidth} x ${innerHeight}. - <br /><br /> - <em>User agent: ${userAgent}</em> -</details> - `; - - const url = ` - ${mrUrl}/api/v4/projects/${projectId}/merge_requests/${mergeRequestId}/discussions`; - - const body = `${commentText} ${detailText}`; - - fetch(url, { - method: 'POST', - headers: { - 'PRIVATE-TOKEN': token, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ body }), - }) - .then(response => { - if (response.ok) { - return response.json(); - } - - throw new Error(`${response.status}: ${response.statusText}`); - }) - .then(data => { - const commentId = data.notes[0].id; - const feedbackLink = `${mrUrl}/${projectPath}/merge_requests/${mergeRequestId}#note_${commentId}`; - const feedbackInfo = `Feedback sent. View at <a class="gitlab-link" href="${feedbackLink}">${projectPath} #${mergeRequestId} (comment ${commentId})</a>`; - confirmAndClear(feedbackInfo); - }) - .catch(err => { - postError( - `Your comment could not be sent. Please try again. Error: ${err.message}`, - COMMENT_BOX, - ); - resetCommentBox(); - resetCommentButton(); - }); -}; - -export { comment, postComment }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/constants.js b/app/assets/javascripts/visual_review_toolbar/components/constants.js deleted file mode 100644 index 07fcb179d15..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/components/constants.js +++ /dev/null @@ -1,41 +0,0 @@ -// component selectors -const COLLAPSE_BUTTON = 'gitlab-collapse'; -const COMMENT_BOX = 'gitlab-comment'; -const COMMENT_BUTTON = 'gitlab-comment-button'; -const FORM = 'gitlab-form'; -const FORM_CONTAINER = 'gitlab-form-wrapper'; -const LOGIN = 'gitlab-login'; -const LOGOUT = 'gitlab-logout-button'; -const NOTE = 'gitlab-validation-note'; -const NOTE_CONTAINER = 'gitlab-note-wrapper'; -const REMEMBER_TOKEN = 'gitlab-remember_token'; -const REVIEW_CONTAINER = 'gitlab-review-container'; -const TOKEN_BOX = 'gitlab-token'; - -// colors — these are applied programmatically -// rest of styles belong in ./styles -const BLACK = 'rgba(46, 46, 46, 1)'; -const CLEAR = 'rgba(255, 255, 255, 0)'; -const MUTED = 'rgba(223, 223, 223, 0.5)'; -const RED = 'rgba(219, 59, 33, 1)'; -const WHITE = 'rgba(250, 250, 250, 1)'; - -export { - COLLAPSE_BUTTON, - COMMENT_BOX, - COMMENT_BUTTON, - FORM, - FORM_CONTAINER, - LOGIN, - LOGOUT, - NOTE, - NOTE_CONTAINER, - REMEMBER_TOKEN, - REVIEW_CONTAINER, - TOKEN_BOX, - BLACK, - CLEAR, - MUTED, - RED, - WHITE, -}; diff --git a/app/assets/javascripts/visual_review_toolbar/components/index.js b/app/assets/javascripts/visual_review_toolbar/components/index.js deleted file mode 100644 index 50b52d7d3a2..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/components/index.js +++ /dev/null @@ -1,33 +0,0 @@ -import { comment, postComment } from './comment'; -import { - COLLAPSE_BUTTON, - COMMENT_BUTTON, - FORM_CONTAINER, - LOGIN, - LOGOUT, - REVIEW_CONTAINER, -} from './constants'; -import { authorizeUser, login } from './login'; -import { note } from './note'; -import { selectContainer } from './utils'; -import { buttonAndForm, logoutUser, toggleForm } from './wrapper'; -import { collapseButton } from './wrapper_icons'; - -export { - authorizeUser, - buttonAndForm, - collapseButton, - comment, - login, - logoutUser, - note, - postComment, - selectContainer, - toggleForm, - COLLAPSE_BUTTON, - COMMENT_BUTTON, - FORM_CONTAINER, - LOGIN, - LOGOUT, - REVIEW_CONTAINER, -}; diff --git a/app/assets/javascripts/visual_review_toolbar/components/login.js b/app/assets/javascripts/visual_review_toolbar/components/login.js deleted file mode 100644 index 0a71299f041..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/components/login.js +++ /dev/null @@ -1,51 +0,0 @@ -import { LOGIN, REMEMBER_TOKEN, TOKEN_BOX } from './constants'; -import { clearNote, postError } from './note'; -import { buttonClearStyles, selectRemember, selectToken } from './utils'; -import { addCommentForm } from './wrapper'; - -const login = ` - <div> - <label for="${TOKEN_BOX}" class="gitlab-label">Enter your <a class="gitlab-link" href="https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html">personal access token</a></label> - <input class="gitlab-input" type="password" id="${TOKEN_BOX}" name="${TOKEN_BOX}" aria-required="true" autocomplete="current-password"> - </div> - <div class="gitlab-checkbox-wrapper"> - <input type="checkbox" id="${REMEMBER_TOKEN}" name="${REMEMBER_TOKEN}" value="remember"> - <label for="${REMEMBER_TOKEN}" class="gitlab-checkbox-label">Remember me</label> - </div> - <div class="gitlab-button-wrapper"> - <button class="gitlab-button-wide gitlab-button gitlab-button-success" style="${buttonClearStyles}" type="button" id="${LOGIN}"> Submit </button> - </div> -`; - -const storeToken = (token, state) => { - const { localStorage } = window; - const rememberMe = selectRemember().checked; - - // All the browsers we support have localStorage, so let's silently fail - // and go on with the rest of the functionality. - try { - if (rememberMe) { - localStorage.setItem('token', token); - } - } finally { - state.token = token; - } -}; - -const authorizeUser = state => { - // Clear any old errors - clearNote(TOKEN_BOX); - - const token = selectToken().value; - - if (!token) { - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - postError('Please enter your token.', TOKEN_BOX); - return; - } - - storeToken(token, state); - addCommentForm(); -}; - -export { authorizeUser, login }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/note.js b/app/assets/javascripts/visual_review_toolbar/components/note.js deleted file mode 100644 index 0150f640aae..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/components/note.js +++ /dev/null @@ -1,35 +0,0 @@ -import { NOTE, NOTE_CONTAINER, RED } from './constants'; -import { selectById, selectNote, selectNoteContainer } from './utils'; - -const note = ` - <div id="${NOTE_CONTAINER}" style="visibility: hidden;"> - <p id="${NOTE}" class="gitlab-message"></p> - </div> -`; - -const clearNote = inputId => { - const currentNote = selectNote(); - const noteContainer = selectNoteContainer(); - - currentNote.innerText = ''; - currentNote.style.color = ''; - noteContainer.style.visibility = 'hidden'; - - if (inputId) { - const field = document.getElementById(inputId); - field.style.borderColor = ''; - } -}; - -const postError = (message, inputId) => { - const currentNote = selectNote(); - const noteContainer = selectNoteContainer(); - const field = selectById(inputId); - field.style.borderColor = RED; - currentNote.style.color = RED; - currentNote.innerText = message; - noteContainer.style.visibility = 'visible'; - setTimeout(clearNote.bind(null, inputId), 5000); -}; - -export { clearNote, note, postError }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/utils.js b/app/assets/javascripts/visual_review_toolbar/components/utils.js deleted file mode 100644 index 00f4460925d..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/components/utils.js +++ /dev/null @@ -1,48 +0,0 @@ -/* global document */ - -import { - COLLAPSE_BUTTON, - COMMENT_BOX, - COMMENT_BUTTON, - FORM, - FORM_CONTAINER, - NOTE, - NOTE_CONTAINER, - REMEMBER_TOKEN, - REVIEW_CONTAINER, - TOKEN_BOX, -} from './constants'; - -// this style must be applied inline in a handful of components -/* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ -const buttonClearStyles = ` - -webkit-appearance: none; -`; - -// selector functions to abstract out a little -const selectById = id => document.getElementById(id); -const selectCollapseButton = () => document.getElementById(COLLAPSE_BUTTON); -const selectCommentBox = () => document.getElementById(COMMENT_BOX); -const selectCommentButton = () => document.getElementById(COMMENT_BUTTON); -const selectContainer = () => document.getElementById(REVIEW_CONTAINER); -const selectForm = () => document.getElementById(FORM); -const selectFormContainer = () => document.getElementById(FORM_CONTAINER); -const selectNote = () => document.getElementById(NOTE); -const selectNoteContainer = () => document.getElementById(NOTE_CONTAINER); -const selectRemember = () => document.getElementById(REMEMBER_TOKEN); -const selectToken = () => document.getElementById(TOKEN_BOX); - -export { - buttonClearStyles, - selectById, - selectCollapseButton, - selectContainer, - selectCommentBox, - selectCommentButton, - selectForm, - selectFormContainer, - selectNote, - selectNoteContainer, - selectRemember, - selectToken, -}; diff --git a/app/assets/javascripts/visual_review_toolbar/components/wrapper.js b/app/assets/javascripts/visual_review_toolbar/components/wrapper.js deleted file mode 100644 index f2eaf1d7916..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/components/wrapper.js +++ /dev/null @@ -1,102 +0,0 @@ -import { comment } from './comment'; -import { CLEAR, FORM, FORM_CONTAINER, WHITE } from './constants'; -import { login } from './login'; -import { clearNote } from './note'; -import { - selectCollapseButton, - selectForm, - selectFormContainer, - selectNoteContainer, -} from './utils'; -import { commentIcon, compressIcon } from './wrapper_icons'; - -const form = content => ` - <form id="${FORM}"> - ${content} - </form> -`; - -const buttonAndForm = ({ content, toggleButton }) => ` - <div id="${FORM_CONTAINER}" class="gitlab-form-open"> - ${toggleButton} - ${form(content)} - </div> -`; - -const addCommentForm = () => { - const formWrapper = selectForm(); - formWrapper.innerHTML = comment; -}; - -const addLoginForm = () => { - const formWrapper = selectForm(); - formWrapper.innerHTML = login; -}; - -function logoutUser() { - const { localStorage } = window; - - // All the browsers we support have localStorage, so let's silently fail - // and go on with the rest of the functionality. - try { - localStorage.removeItem('token'); - } catch (err) { - return; - } - - clearNote(); - addLoginForm(); -} - -function toggleForm() { - const collapseButton = selectCollapseButton(); - const currentForm = selectForm(); - const formContainer = selectFormContainer(); - const noteContainer = selectNoteContainer(); - const OPEN = 'open'; - const CLOSED = 'closed'; - - /* - You may wonder why we spread the arrays before we reverse them. - In the immortal words of MDN, - Careful: reverse is destructive. It also changes the original array - */ - - const openButtonClasses = ['gitlab-collapse-closed', 'gitlab-collapse-open']; - const closedButtonClasses = [...openButtonClasses].reverse(); - const openContainerClasses = ['gitlab-wrapper-closed', 'gitlab-wrapper-open']; - const closedContainerClasses = [...openContainerClasses].reverse(); - - const stateVals = { - [OPEN]: { - buttonClasses: openButtonClasses, - containerClasses: openContainerClasses, - icon: compressIcon, - display: 'flex', - backgroundColor: WHITE, - }, - [CLOSED]: { - buttonClasses: closedButtonClasses, - containerClasses: closedContainerClasses, - icon: commentIcon, - display: 'none', - backgroundColor: CLEAR, - }, - }; - - const nextState = collapseButton.classList.contains('gitlab-collapse-open') ? CLOSED : OPEN; - const currentVals = stateVals[nextState]; - - formContainer.classList.replace(...currentVals.containerClasses); - formContainer.style.backgroundColor = currentVals.backgroundColor; - formContainer.classList.toggle('gitlab-form-open'); - currentForm.style.display = currentVals.display; - collapseButton.classList.replace(...currentVals.buttonClasses); - collapseButton.innerHTML = currentVals.icon; - - if (noteContainer && noteContainer.innerText.length > 0) { - noteContainer.style.display = currentVals.display; - } -} - -export { addCommentForm, addLoginForm, buttonAndForm, logoutUser, toggleForm }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/wrapper_icons.js b/app/assets/javascripts/visual_review_toolbar/components/wrapper_icons.js deleted file mode 100644 index b686fd4f5c2..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/components/wrapper_icons.js +++ /dev/null @@ -1,15 +0,0 @@ -import { buttonClearStyles } from './utils'; - -const commentIcon = ` - <svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>icn/comment</title><path d="M4 11.132l1.446-.964A1 1 0 0 1 6 10h5a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v6.132zM6.303 12l-2.748 1.832A1 1 0 0 1 2 13V5a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v4a3 3 0 0 1-3 3H6.303z" id="gitlab-comment-icon"/></svg> -`; - -const compressIcon = ` - <svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>icn/compress</title><path d="M5.27 12.182l-1.562 1.561a1 1 0 0 1-1.414 0h-.001a1 1 0 0 1 0-1.415l1.56-1.56L2.44 9.353a.5.5 0 0 1 .353-.854H7.09a.5.5 0 0 1 .5.5v4.294a.5.5 0 0 1-.853.353l-1.467-1.465zm6.911-6.914l1.464 1.464a.5.5 0 0 1-.353.854H8.999a.5.5 0 0 1-.5-.5V2.793a.5.5 0 0 1 .854-.354l1.414 1.415 1.56-1.561a1 1 0 1 1 1.415 1.414l-1.561 1.56z" id="gitlab-compress-icon"/></svg> -`; - -const collapseButton = ` - <button id='gitlab-collapse' style='${buttonClearStyles}' class='gitlab-button gitlab-button-secondary gitlab-collapse gitlab-collapse-open'>${compressIcon}</button> -`; - -export { commentIcon, compressIcon, collapseButton }; diff --git a/app/assets/javascripts/visual_review_toolbar/index.js b/app/assets/javascripts/visual_review_toolbar/index.js deleted file mode 100644 index f94eb88835a..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/index.js +++ /dev/null @@ -1,36 +0,0 @@ -import './styles/toolbar.css'; - -import { buttonAndForm, note, selectContainer, REVIEW_CONTAINER } from './components'; -import { debounce, eventLookup, getInitialView, initializeState, updateWindowSize } from './store'; - -/* - - Welcome to the visual review toolbar files. A few useful notes: - - - These files build a static script that is served from our webpack - assets folder. (https://gitlab.com/assets/webpack/visual_review_toolbar.js) - - - To compile this file, run `yarn webpack-vrt`. - - - Vue is not used in these files because we do not want to ask users to - install another library at this time. It's all pure vanilla javascript. - -*/ - -window.addEventListener('load', () => { - initializeState(window, document); - - const mainContent = buttonAndForm(getInitialView(window)); - const container = document.createElement('div'); - container.setAttribute('id', REVIEW_CONTAINER); - container.insertAdjacentHTML('beforeend', note); - container.insertAdjacentHTML('beforeend', mainContent); - - document.body.insertBefore(container, document.body.firstChild); - - selectContainer().addEventListener('click', event => { - eventLookup(event)(); - }); - - window.addEventListener('resize', debounce(updateWindowSize.bind(null, window), 200)); -}); diff --git a/app/assets/javascripts/visual_review_toolbar/store/events.js b/app/assets/javascripts/visual_review_toolbar/store/events.js deleted file mode 100644 index 93996be8473..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/store/events.js +++ /dev/null @@ -1,36 +0,0 @@ -import { - authorizeUser, - logoutUser, - postComment, - toggleForm, - COLLAPSE_BUTTON, - COMMENT_BUTTON, - LOGIN, - LOGOUT, -} from '../components'; - -import { state } from './state'; - -const noop = () => {}; - -const eventLookup = ({ target: { id } }) => { - switch (id) { - case COLLAPSE_BUTTON: - return toggleForm; - case COMMENT_BUTTON: - return postComment.bind(null, state); - case LOGIN: - return authorizeUser.bind(null, state); - case LOGOUT: - return logoutUser; - default: - return noop; - } -}; - -const updateWindowSize = wind => { - state.innerWidth = wind.innerWidth; - state.innerHeight = wind.innerHeight; -}; - -export { eventLookup, updateWindowSize }; diff --git a/app/assets/javascripts/visual_review_toolbar/store/index.js b/app/assets/javascripts/visual_review_toolbar/store/index.js deleted file mode 100644 index 7143588c0bf..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/store/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import { eventLookup, updateWindowSize } from './events'; -import { getInitialView, initializeState } from './state'; -import debounce from './utils'; - -export { debounce, eventLookup, getInitialView, initializeState, updateWindowSize }; diff --git a/app/assets/javascripts/visual_review_toolbar/store/state.js b/app/assets/javascripts/visual_review_toolbar/store/state.js deleted file mode 100644 index 22702d524b8..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/store/state.js +++ /dev/null @@ -1,78 +0,0 @@ -import { comment, login, collapseButton } from '../components'; - -const state = { - browser: '', - href: '', - innerWidth: '', - innerHeight: '', - mergeRequestId: '', - mrUrl: '', - platform: '', - projectId: '', - userAgent: '', - token: '', -}; - -// adapted from https://developer.mozilla.org/en-US/docs/Web/API/Window/navigator#Example_2_Browser_detect_and_return_an_index -const getBrowserId = sUsrAg => { - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - const aKeys = ['MSIE', 'Edge', 'Firefox', 'Safari', 'Chrome', 'Opera']; - let nIdx = aKeys.length - 1; - - for (nIdx; nIdx > -1 && sUsrAg.indexOf(aKeys[nIdx]) === -1; nIdx -= 1); - return aKeys[nIdx]; -}; - -const initializeState = (wind, doc) => { - const { - innerWidth, - innerHeight, - location: { href }, - navigator: { platform, userAgent }, - } = wind; - - const browser = getBrowserId(userAgent); - - const scriptEl = doc.getElementById('review-app-toolbar-script'); - const { projectId, mergeRequestId, mrUrl, projectPath } = scriptEl.dataset; - - // This mutates our default state object above. It's weird but it makes the linter happy. - Object.assign(state, { - browser, - href, - innerWidth, - innerHeight, - mergeRequestId, - mrUrl, - platform, - projectId, - projectPath, - userAgent, - }); -}; - -function getInitialView({ localStorage }) { - const loginView = { - content: login, - toggleButton: collapseButton, - }; - - const commentView = { - content: comment, - toggleButton: collapseButton, - }; - - try { - const token = localStorage.getItem('token'); - - if (token) { - state.token = token; - return commentView; - } - return loginView; - } catch (err) { - return loginView; - } -} - -export { initializeState, getInitialView, state }; diff --git a/app/assets/javascripts/visual_review_toolbar/store/utils.js b/app/assets/javascripts/visual_review_toolbar/store/utils.js deleted file mode 100644 index 5cf145351b3..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/store/utils.js +++ /dev/null @@ -1,15 +0,0 @@ -const debounce = (fn, time) => { - let current; - - const debounced = () => { - if (current) { - clearTimeout(current); - } - - current = setTimeout(fn, time); - }; - - return debounced; -}; - -export default debounce; diff --git a/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css b/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css deleted file mode 100644 index 00a55c0027a..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css +++ /dev/null @@ -1,177 +0,0 @@ -/* - As a standalone script, the toolbar has its own css - */ - -#gitlab-collapse > * { - pointer-events: none; -} - -#gitlab-comment { - background-color: #fafafa; -} - -#gitlab-form { - display: flex; - flex-direction: column; - width: 100%; - margin-bottom: 0; -} - -#gitlab-note-wrapper { - display: flex; - flex-direction: column; - background-color: #fafafa; - border-radius: 4px; - margin-bottom: .5rem; - padding: 1rem; -} - -#gitlab-form-wrapper { - overflow: auto; - display: flex; - flex-direction: row-reverse; - border-radius: 4px; -} - -#gitlab-review-container { - max-width: 22rem; - max-height: 22rem; - overflow: auto; - display: flex; - flex-direction: column; - position: fixed; - bottom: 1rem; - right: 1rem; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, - 'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', - 'Noto Color Emoji'; - font-size: .8rem; - font-weight: 400; - color: #2e2e2e; -} - -.gitlab-wrapper-open { - max-width: 22rem; - max-height: 22rem; -} - -.gitlab-wrapper-closed { - max-width: 3.4rem; - max-height: 3.4rem; -} - -.gitlab-button { - cursor: pointer; - transition: background-color 100ms linear, border-color 100ms linear, color 100ms linear, box-shadow 100ms linear; -} - -.gitlab-button-secondary { - background: none #fafafa; - margin: 0 .5rem; - border: 1px solid #e3e3e3; -} - -.gitlab-button-secondary:hover { - background-color: #f0f0f0; - border-color: #e3e3e3; - color: #2e2e2e; -} - -.gitlab-button-secondary:active { - color: #2e2e2e; - background-color: #e1e1e1; - border-color: #dadada; -} - -.gitlab-button-success:hover { - color: #fff; - background-color: #137e3f; - border-color: #127339; -} - -.gitlab-button-success:active { - background-color: #168f48; - border-color: #12753a; - color: #fff; -} - -.gitlab-button-success { - background-color: #1aaa55; - border: 1px solid #168f48; - color: #fff; -} - -.gitlab-button-wide { - width: 100%; -} - -.gitlab-button-wrapper { - margin-top: 1rem; - display: flex; - align-items: baseline; - justify-content: flex-end; -} - -.gitlab-collapse { - width: 2.4rem; - height: 2.2rem; - margin-left: 1rem; - padding: .5rem; -} - -.gitlab-collapse-closed { - align-self: center; -} - -.gitlab-checkbox-label { - padding: 0 .2rem; -} - -.gitlab-checkbox-wrapper { - display: flex; - align-items: baseline; -} - -.gitlab-form-open { - padding: 1rem; - background-color: #fafafa; -} - -.gitlab-label { - font-weight: 600; - display: inline-block; - width: 100%; -} - -.gitlab-link { - color: #1b69b6; - text-decoration: none; - background-color: transparent; - background-image: none; -} - -.gitlab-link:hover { - text-decoration: underline; -} - -.gitlab-message { - padding: .25rem 0; - margin: 0; - line-height: 1.2rem; -} - -.gitlab-metadata-note { - font-size: .7rem; - line-height: 1rem; - color: #666; - margin-bottom: 0; -} - -.gitlab-input { - width: 100%; - border: 1px solid #dfdfdf; - border-radius: 4px; - padding: .1rem .2rem; - min-height: 2rem; - max-width: 17rem; -} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue index 34cdb70ce14..bb6921225c2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue @@ -14,6 +14,8 @@ import ReviewAppLink from './review_app_link.vue'; import MRWidgetService from '../services/mr_widget_service'; export default { + // name: 'Deployment' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings name: 'Deployment', components: { LoadingButton, @@ -125,7 +127,9 @@ export default { this.isStopping = false; }) .catch(() => { - createFlash('Something went wrong while stopping this environment. Please try again.'); + createFlash( + __('Something went wrong while stopping this environment. Please try again.'), + ); this.isStopping = false; }); } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index e20a16900d4..fb826be19f5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -139,7 +139,7 @@ export default { type="button" class="btn dropdown-toggle qa-dropdown-toggle" data-toggle="dropdown" - aria-label="Download as" + :aria-label="__('Download as')" aria-haspopup="true" aria-expanded="false" > 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 4b57693e8f1..57d4d8b7ae6 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 @@ -14,6 +14,6 @@ export default { <template> <div class="circle-icon-container append-right-default align-self-start align-self-lg-center"> - <icon :name="name" /> + <icon :name="name" :size="24" /> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue index a347269c916..53bf9d5ab6f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue @@ -23,7 +23,7 @@ export default { }; </script> <template> - <section class="mr-widget-help"> + <section class="mr-widget-help font-italic"> <template v-if="missingBranch"> {{ missingBranchInfo }} </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index f5fa68308bc..40c095aa954 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -96,16 +96,14 @@ export default { <template> <div class="ci-widget media js-ci-widget"> <template v-if="!hasPipeline || hasCIError"> - <div - class="add-border ci-status-icon ci-status-icon-failed ci-error js-ci-error append-right-default" - > - <icon :size="32" name="status_failed_borderless" /> + <div class="add-border ci-status-icon ci-status-icon-failed ci-error js-ci-error"> + <icon :size="24" name="status_failed_borderless" /> </div> - <div class="media-body" v-html="errorText"></div> + <div class="media-body prepend-left-default" v-html="errorText"></div> </template> <template v-else-if="hasPipeline"> <a :href="status.details_path" class="align-self-start append-right-default"> - <ci-icon :status="status" :size="32" :borderless="true" class="add-border" /> + <ci-icon :status="status" :size="24" :borderless="true" class="add-border" /> </a> <div class="ci-widget-container d-flex"> <div class="ci-widget-content"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue index 17ac8ada32d..8fdf61a6b8d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue @@ -18,8 +18,8 @@ export default { Deployment, MrWidgetContainer, MrWidgetPipeline, - MergeTrainInfo: () => - import('ee_component/vue_merge_request_widget/components/merge_train_info.vue'), + MergeTrainPositionIndicator: () => + import('ee_component/vue_merge_request_widget/components/merge_train_position_indicator.vue'), }, props: { mr: { @@ -60,9 +60,9 @@ export default { return this.isPostMerge ? this.mr.mergePipeline : this.mr.pipeline; }, showVisualReviewAppLink() { - return Boolean(this.mr.visualReviewFF && this.mr.visualReviewAppAvailable); + return this.mr.visualReviewAppAvailable; }, - showMergeTrainInfo() { + showMergeTrainPositionIndicator() { return _.isNumber(this.mr.mergeTrainIndex); }, }, @@ -90,8 +90,8 @@ export default { :visual-review-app-meta="visualReviewAppMeta" /> </div> - <merge-train-info - v-if="showMergeTrainInfo" + <merge-train-position-indicator + v-if="showMergeTrainPositionIndicator" class="mr-widget-extension" :merge-train-index="mr.mergeTrainIndex" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue index 392eb6fb425..d0df8309dc7 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue @@ -32,10 +32,13 @@ export default { }; </script> <template> - <div class="space-children d-flex append-right-10 widget-status-icon"> - <div v-if="isLoading" class="mr-widget-icon"><gl-loading-icon size="md" /></div> - - <ci-icon v-else :status="statusObj" :size="24" /> + <div class="d-flex align-self-start"> + <div class="square s24 h-auto d-flex-center append-right-default"> + <div v-if="isLoading" class="mr-widget-icon d-inline-flex"> + <gl-loading-icon size="md" class="mr-loading-icon d-inline-flex" /> + </div> + <ci-icon v-else :status="statusObj" :size="24" /> + </div> <button v-if="showDisabledButton" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue index 0312b147b62..01524f4b650 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue @@ -83,7 +83,7 @@ export default { <gl-button :aria-label="ariaLabel" variant="blank" - class="commit-edit-toggle square s24 mr-2" + class="commit-edit-toggle square s24 append-right-default" @click.stop="toggle()" > <icon :name="collapseIcon" :size="16" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue index 5958c2cf87e..8e8e67228ed 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue @@ -6,6 +6,7 @@ import statusIcon from '../mr_widget_status_icon.vue'; import MrWidgetAuthor from '../../components/mr_widget_author.vue'; import eventHub from '../../event_hub'; import { AUTO_MERGE_STRATEGIES } from '../../constants'; +import { __ } from '~/locale'; export default { name: 'MRWidgetAutoMergeEnabled', @@ -55,7 +56,7 @@ export default { }) .catch(() => { this.isCancellingAutoMerge = false; - Flash('Something went wrong. Please try again.'); + Flash(__('Something went wrong. Please try again.')); }); }, removeSourceBranch() { @@ -76,7 +77,7 @@ export default { }) .catch(() => { this.isRemovingSourceBranch = false; - Flash('Something went wrong. Please try again.'); + Flash(__('Something went wrong. Please try again.')); }); }, }, @@ -107,15 +108,15 @@ export default { <section class="mr-info-list"> <p> {{ s__('mrWidget|The changes will be merged into') }} - <a :href="mr.targetBranchPath" class="label-branch"> {{ mr.targetBranch }} </a> + <a :href="mr.targetBranchPath" class="label-branch">{{ mr.targetBranch }}</a> </p> <p v-if="mr.shouldRemoveSourceBranch"> {{ s__('mrWidget|The source branch will be deleted') }} </p> <p v-else class="d-flex align-items-start"> - <span class="append-right-10"> - {{ s__('mrWidget|The source branch will not be deleted') }} - </span> + <span class="append-right-10">{{ + s__('mrWidget|The source branch will not be deleted') + }}</span> <a v-if="canRemoveSourceBranch" :disabled="isRemovingSourceBranch" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue index b9562fbc260..fb07c03e34d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue @@ -1,4 +1,5 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import Flash from '~/flash'; import tooltip from '~/vue_shared/directives/tooltip'; import { s__, __ } from '~/locale'; @@ -84,6 +85,8 @@ export default { .removeSourceBranch() .then(res => res.data) .then(data => { + // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings if (data.message === 'Branch was deleted') { eventHub.$emit('MRWidgetUpdateRequested', () => { this.isMakingRequest = false; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index 0bcccc50eb2..339e154affc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -4,6 +4,7 @@ import simplePoll from '../../../lib/utils/simple_poll'; import eventHub from '../../event_hub'; import statusIcon from '../mr_widget_status_icon.vue'; import Flash from '../../../flash'; +import { __, sprintf } from '~/locale'; export default { name: 'MRWidgetRebase', @@ -40,6 +41,18 @@ export default { showDisabledButton() { return ['failed', 'loading'].includes(this.status); }, + fastForwardMergeText() { + return sprintf( + __( + `Fast-forward merge is not possible. Rebase the source branch onto %{startTag}${this.mr.targetBranch}%{endTag} to allow this merge request to be merged.`, + ), + { + startTag: '<span class="label-branch">', + endTag: '</span>', + }, + false, + ); + }, }, methods: { rebase() { @@ -54,7 +67,7 @@ export default { .catch(error => { this.rebasingError = error.merge_error; this.isMakingRequest = false; - Flash('Something went wrong. Please try again.'); + Flash(__('Something went wrong. Please try again.')); }); }, checkRebaseStatus(continuePolling, stopPolling) { @@ -69,7 +82,7 @@ export default { if (res.merge_error && res.merge_error.length) { this.rebasingError = res.merge_error; - Flash('Something went wrong. Please try again.'); + Flash(__('Something went wrong. Please try again.')); } eventHub.$emit('MRWidgetRebaseSuccess'); @@ -78,7 +91,7 @@ export default { }) .catch(() => { this.isMakingRequest = false; - Flash('Something went wrong. Please try again.'); + Flash(__('Something went wrong. Please try again.')); stopPolling(); }); }, @@ -91,19 +104,14 @@ export default { <div class="rebase-state-find-class-convention media media-body space-children"> <template v-if="mr.rebaseInProgress || isMakingRequest"> - <span class="bold"> Rebase in progress </span> + <span class="bold">{{ __('Rebase in progress') }}</span> </template> <template v-if="!mr.rebaseInProgress && !mr.canPushToSourceBranch"> - <span class="bold"> - Fast-forward merge is not possible. Rebase the source branch onto - <span class="label-branch">{{ mr.targetBranch }}</span> to allow this merge request to be - merged. - </span> + <span class="bold" v-html="fastForwardMergeText"></span> </template> <template v-if="!mr.rebaseInProgress && mr.canPushToSourceBranch && !isMakingRequest"> <div - class="accept-merge-holder clearfix -js-toggle-container accept-action media space-children" + class="accept-merge-holder clearfix js-toggle-container accept-action media space-children" > <button :disabled="isMakingRequest" @@ -111,14 +119,14 @@ js-toggle-container accept-action media space-children" class="btn btn-sm btn-reopen btn-success qa-mr-rebase-button" @click="rebase" > - <gl-loading-icon v-if="isMakingRequest" /> - Rebase + <gl-loading-icon v-if="isMakingRequest" />{{ __('Rebase') }} </button> - <span v-if="!rebasingError" class="bold"> - Fast-forward merge is not possible. Rebase the source branch onto the target branch or - merge target branch into source branch to allow this merge request to be merged. - </span> - <span v-else class="bold danger"> {{ rebasingError }} </span> + <span v-if="!rebasingError" class="bold">{{ + __( + 'Fast-forward merge is not possible. Rebase the source branch onto the target branch or merge target branch into source branch to allow this merge request to be merged.', + ) + }}</span> + <span v-else class="bold danger">{{ rebasingError }}</span> </div> </template> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue index a38495bb4cc..4d7d49398eb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue @@ -18,23 +18,35 @@ export default { <template> <div class="mr-widget-body mr-widget-empty-state"> <div class="row"> - <div class="artwork col-md-5 order-md-last col-12 text-center"> + <div + class="artwork col-md-5 order-md-last col-12 text-center d-flex justify-content-center align-items-center" + > <span v-html="emptyStateSVG"></span> </div> <div class="text col-md-7 order-md-first col-12"> - <span> - Merge requests are a place to propose changes you have made to a project and discuss those - changes with others. - </span> - <p>Interested parties can even contribute by pushing commits if they want to.</p> + <span>{{ + s__( + 'mrWidgetNothingToMerge|Merge requests are a place to propose changes you have made to a project and discuss those changes with others.', + ) + }}</span> <p> - Currently there are no changes in this merge request's source branch. Please push new - commits or use a different branch. + {{ + s__( + 'mrWidgetNothingToMerge|Interested parties can even contribute by pushing commits if they want to.', + ) + }} + </p> + <p> + {{ + s__( + "mrWidgetNothingToMerge|Currently there are no changes in this merge request's source branch. Please push new commits or use a different branch.", + ) + }} </p> <div> - <a v-if="mr.newBlobPath" :href="mr.newBlobPath" class="btn btn-inverted btn-success"> - Create file - </a> + <a v-if="mr.newBlobPath" :href="mr.newBlobPath" class="btn btn-inverted btn-success">{{ + __('Create file') + }}</a> </div> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index ca1b4a57717..e294e1de976 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -6,6 +6,7 @@ import simplePoll from '~/lib/utils/simple_poll'; import { __ } from '~/locale'; import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge'; import MergeRequest from '../../../merge_request'; +import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import Flash from '../../../flash'; import statusIcon from '../mr_widget_status_icon.vue'; import eventHub from '../../event_hub'; @@ -93,9 +94,6 @@ export default { return __('Merge'); }, - shouldShowMergeOptionsDropdown() { - return this.isAutoMergeAvailable && !this.mr.onlyAllowMergeIfPipelineSucceeds; - }, isRemoveSourceBranchButtonDisabled() { return this.isMergeButtonDisabled; }, @@ -174,6 +172,8 @@ export default { MergeRequest.decreaseCounter(); stopPolling(); + refreshUserMergeRequestCounts(); + // If user checked remove source branch and we didn't remove the branch yet // we should start another polling for source branch remove process if (this.removeSourceBranch && data.source_branch_exists) { @@ -243,17 +243,17 @@ export default { {{ mergeButtonText }} </button> <button - v-if="isAutoMergeAvailable" + v-if="shouldShowMergeImmediatelyDropdown" :disabled="isMergeButtonDisabled" type="button" class="btn btn-sm btn-info dropdown-toggle js-merge-moment" data-toggle="dropdown" - aria-label="Select merge moment" + :aria-label="__('Select merge moment')" > <i class="fa fa-chevron-down qa-merge-moment-dropdown" aria-hidden="true"></i> </button> <ul - v-if="shouldShowMergeOptionsDropdown" + v-if="shouldShowMergeImmediatelyDropdown" class="dropdown-menu dropdown-menu-right" role="menu" > diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue index accb9d9fef1..98f682c2e8a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue @@ -36,7 +36,7 @@ export default { :disabled="isDisabled" type="checkbox" name="squash" - class="qa-squash-checkbox" + class="qa-squash-checkbox js-squash-checkbox" @change="$emit('input', $event.target.checked)" /> {{ __('Squash commits') }} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue index a9fb40a4949..d4a5fdb4b97 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue @@ -20,7 +20,7 @@ export default { <status-icon :show-disabled-button="true" status="warning" /> <div class="media-body space-children"> <span class="bold"> - {{ s__('mrWidget|There are unresolved discussions. Please resolve these discussions') }} + {{ s__('mrWidget|There are unresolved threads. Please resolve these threads') }} </span> <a v-if="mr.createIssueToResolveDiscussionsPath" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue index 7c322388d30..91c0b40a0b5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue @@ -46,14 +46,20 @@ export default { <status-icon :show-disabled-button="Boolean(mr.removeWIPPath)" status="warning" /> <div class="media-body space-children"> <span class="bold"> - This is a Work in Progress + {{ __('This is a Work in Progress') }} <i v-tooltip class="fa fa-question-circle" - title="When this merge request is ready, - remove the WIP: prefix from the title to allow it to be merged" - aria-label="When this merge request is ready, - remove the WIP: prefix from the title to allow it to be merged" + :title=" + s__( + 'mrWidget|When this merge request is ready, remove the WIP: prefix from the title to allow it to be merged', + ) + " + :aria-label=" + s__( + 'mrWidget|When this merge request is ready, remove the WIP: prefix from the title to allow it to be merged', + ) + " > </i> </span> @@ -64,8 +70,8 @@ export default { class="btn btn-default btn-sm js-remove-wip" @click="removeWIP" > - <i v-if="isMakingRequest" class="fa fa-spinner fa-spin" aria-hidden="true"> </i> Resolve WIP - status + <i v-if="isMakingRequest" class="fa fa-spinner fa-spin" aria-hidden="true"> </i> + {{ s__('mrWidget|Resolve WIP status') }} </button> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js index 116d537c463..eef49e20159 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js @@ -15,5 +15,8 @@ export default { // MWPS is currently the only auto merge strategy available in CE return __('Merge when pipeline succeeds'); }, + shouldShowMergeImmediatelyDropdown() { + return this.mr.isPipelineActive && !this.mr.onlyAllowMergeIfPipelineSucceeds; + }, }, }; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index a79da476890..edd21a81f8b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -40,6 +40,8 @@ import { setFaviconOverlay } from '../lib/utils/common_utils'; export default { el: '#js-vue-mr-widget', + // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25 + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings name: 'MRWidget', components: { 'mr-widget-header': WidgetHeader, @@ -164,6 +166,7 @@ export default { ciEnvironmentsStatusPath: store.ciEnvironmentsStatusPath, mergeRequestBasicPath: store.mergeRequestBasicPath, mergeRequestWidgetPath: store.mergeRequestWidgetPath, + mergeRequestCachedWidgetPath: store.mergeRequestCachedWidgetPath, mergeActionsContentPath: store.mergeActionsContentPath, rebasePath: store.rebasePath, }; @@ -174,8 +177,7 @@ export default { checkStatus(cb, isRebased) { return this.service .checkStatus() - .then(res => res.data) - .then(data => { + .then(({ data }) => { this.handleNotification(data); this.mr.setData(data, isRebased); this.setFaviconHelper(); @@ -263,8 +265,11 @@ export default { if (!data.pipeline) return; const { label } = data.pipeline.details.status; - const title = `Pipeline ${label}`; - const message = `Pipeline ${label} for "${data.title}"`; + const title = sprintf(__('Pipeline %{label}'), { label }); + const message = sprintf(__('Pipeline %{label} for "%{dataTitle}"'), { + dataTitle: data.title, + label, + }); notify.notifyMe(title, message, this.mr.gitlabLogo); }, diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js index 1dae53039d5..f637a44bf2d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js +++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js @@ -34,7 +34,16 @@ export default class MRWidgetService { } checkStatus() { - return axios.get(this.endpoints.mergeRequestWidgetPath); + // two endpoints are requested in order to get MR info: + // one which is etag-cached and invalidated and another one which is not cached + // the idea is to move all the fields to etag-cached endpoint and then perform only one request + // https://gitlab.com/gitlab-org/gitlab-ce/issues/61559#note_188801390 + const getData = axios.get(this.endpoints.mergeRequestWidgetPath); + const getCachedData = axios.get(this.endpoints.mergeRequestCachedWidgetPath); + + return axios + .all([getData, getCachedData]) + .then(axios.spread((res, cachedRes) => ({ data: Object.assign(res.data, cachedRes.data) }))); } fetchMergeActionsContent() { diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 581fee7477f..7843409f4a7 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -10,6 +10,8 @@ export default class MergeRequestStore { this.sha = data.diff_head_sha; this.gitlabLogo = data.gitlabLogo; + this.setPaths(data); + this.setData(data); } @@ -18,13 +20,9 @@ export default class MergeRequestStore { this.sha = data.diff_head_sha; } - const currentUser = data.current_user; const pipelineStatus = data.pipeline ? data.pipeline.details.status : null; this.squash = data.squash; - this.squashBeforeMergeHelpPath = - this.squashBeforeMergeHelpPath || data.squash_before_merge_help_path; - this.troubleshootingDocsPath = this.troubleshootingDocsPath || data.troubleshooting_docs_path; this.enableSquashBeforeMerge = this.enableSquashBeforeMerge || true; this.iid = data.iid; @@ -35,6 +33,7 @@ export default class MergeRequestStore { this.sourceBranchProtected = data.source_branch_protected; this.conflictsDocsPath = data.conflicts_docs_path; this.mergeRequestPipelinesHelpPath = data.merge_request_pipelines_docs_path; + this.mergeTrainWhenPipelineSucceedsDocsPath = data.merge_train_when_pipeline_succeeds_docs_path; this.mergeStatus = data.merge_status; this.commitMessage = data.default_merge_commit_message; this.shortMergeCommitSha = data.short_merge_commit_sha; @@ -48,7 +47,7 @@ export default class MergeRequestStore { this.postMergeDeployments = this.postMergeDeployments || []; this.commits = data.commits_without_merge_commits || []; this.squashCommitMessage = data.default_squash_commit_message; - this.initRebase(data); + this.rebaseInProgress = data.rebase_in_progress; if (data.issues_links) { const links = data.issues_links; @@ -66,14 +65,7 @@ export default class MergeRequestStore { this.setToAutoMergeBy = MergeRequestStore.formatUserObject(data.merge_user || {}); this.mergeUserId = data.merge_user_id; this.currentUserId = gon.current_user_id; - this.sourceBranchPath = data.source_branch_path; - this.sourceBranchLink = data.source_branch_with_namespace_link; this.mergeError = data.merge_error; - this.targetBranchPath = data.target_branch_commits_path; - this.targetBranchTreePath = data.target_branch_tree_path; - this.conflictResolutionPath = data.conflict_resolution_path; - this.cancelAutoMergePath = data.cancel_auto_merge_path; - this.removeWIPPath = data.remove_wip_path; this.sourceBranchRemoved = !data.source_branch_exists; this.shouldRemoveSourceBranch = data.remove_source_branch || false; this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false; @@ -83,46 +75,20 @@ export default class MergeRequestStore { this.preferredAutoMergeStrategy = MergeRequestStore.getPreferredAutoMergeStrategy( this.availableAutoMergeStrategies, ); - this.mergePath = data.merge_path; this.ffOnlyEnabled = data.ff_only_enabled; this.shouldBeRebased = Boolean(data.should_be_rebased); - this.mergeRequestBasicPath = data.merge_request_basic_path; - this.mergeRequestWidgetPath = data.merge_request_widget_path; - this.emailPatchesPath = data.email_patches_path; - this.plainDiffPath = data.plain_diff_path; - this.newBlobPath = data.new_blob_path; - this.createIssueToResolveDiscussionsPath = data.create_issue_to_resolve_discussions_path; - this.mergeCheckPath = data.merge_check_path; - this.mergeActionsContentPath = data.commit_change_content_path; - this.mergeCommitPath = data.merge_commit_path; this.isRemovingSourceBranch = this.isRemovingSourceBranch || false; this.isOpen = data.state === 'opened'; this.hasMergeableDiscussionsState = data.mergeable_discussions_state === false; - this.canRemoveSourceBranch = currentUser.can_remove_source_branch || false; - this.canMerge = Boolean(data.merge_path); - this.canCreateIssue = currentUser.can_create_issue || false; - this.canCancelAutomaticMerge = Boolean(data.cancel_auto_merge_path); this.isSHAMismatch = this.sha !== data.diff_head_sha; this.canBeMerged = data.can_be_merged || false; this.isMergeAllowed = data.mergeable || false; this.mergeOngoing = data.merge_ongoing; this.allowCollaboration = data.allow_collaboration; - this.targetProjectFullPath = data.target_project_full_path; - this.sourceProjectFullPath = data.source_project_full_path; this.sourceProjectId = data.source_project_id; this.targetProjectId = data.target_project_id; - this.mergePipelinesEnabled = Boolean(data.merge_pipelines_enabled); - this.mergeTrainsCount = data.merge_trains_count || 0; - this.mergeTrainIndex = data.merge_train_index; - - // Cherry-pick and Revert actions related - this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false; - this.canRevertInCurrentMR = currentUser.can_revert_on_current_merge_request || false; - this.cherryPickInForkPath = currentUser.cherry_pick_in_fork_path; - this.revertInForkPath = currentUser.revert_in_fork_path; // CI related - this.ciEnvironmentsStatusPath = data.ci_environments_status_path; this.hasCI = data.has_ci; this.ciStatus = data.ci_status; this.isPipelineFailed = this.ciStatus === 'failed' || this.ciStatus === 'canceled'; @@ -133,8 +99,33 @@ export default class MergeRequestStore { this.isPipelineActive = data.pipeline ? data.pipeline.active : false; this.isPipelineBlocked = pipelineStatus ? pipelineStatus.group === 'manual' : false; this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null; - this.testResultsPath = data.test_reports_path; + this.cancelAutoMergePath = data.cancel_auto_merge_path; + this.canCancelAutomaticMerge = Boolean(data.cancel_auto_merge_path); + + this.newBlobPath = data.new_blob_path; + this.sourceBranchPath = data.source_branch_path; + this.sourceBranchLink = data.source_branch_with_namespace_link; + this.rebasePath = data.rebase_path; + this.targetBranchPath = data.target_branch_commits_path; + this.targetBranchTreePath = data.target_branch_tree_path; + this.conflictResolutionPath = data.conflict_resolution_path; + this.removeWIPPath = data.remove_wip_path; + this.createIssueToResolveDiscussionsPath = data.create_issue_to_resolve_discussions_path; + this.mergePath = data.merge_path; + this.canMerge = Boolean(data.merge_path); + this.mergeCommitPath = data.merge_commit_path; + this.canPushToSourceBranch = data.can_push_to_source_branch; + + const currentUser = data.current_user; + + this.cherryPickInForkPath = currentUser.cherry_pick_in_fork_path; + this.revertInForkPath = currentUser.revert_in_fork_path; + + this.canRemoveSourceBranch = currentUser.can_remove_source_branch || false; + this.canCreateIssue = currentUser.can_create_issue || false; + this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false; + this.canRevertInCurrentMR = currentUser.can_revert_on_current_merge_request || false; this.setState(data); } @@ -161,6 +152,24 @@ export default class MergeRequestStore { } } + setPaths(data) { + // Paths are set on the first load of the page and not auto-refreshed + this.squashBeforeMergeHelpPath = data.squash_before_merge_help_path; + this.troubleshootingDocsPath = data.troubleshooting_docs_path; + this.mergeRequestBasicPath = data.merge_request_basic_path; + this.mergeRequestWidgetPath = data.merge_request_widget_path; + this.mergeRequestCachedWidgetPath = data.merge_request_cached_widget_path; + this.emailPatchesPath = data.email_patches_path; + this.plainDiffPath = data.plain_diff_path; + this.mergeCheckPath = data.merge_check_path; + this.mergeActionsContentPath = data.commit_change_content_path; + this.targetProjectFullPath = data.target_project_full_path; + this.sourceProjectFullPath = data.source_project_full_path; + this.mergeRequestPipelinesHelpPath = data.merge_request_pipelines_docs_path; + this.conflictsDocsPath = data.conflicts_docs_path; + this.ciEnvironmentsStatusPath = data.ci_environments_status_path; + } + get isNothingToMergeState() { return this.state === stateKey.nothingToMerge; } @@ -169,13 +178,6 @@ export default class MergeRequestStore { return this.state === stateKey.merged; } - initRebase(data) { - this.canPushToSourceBranch = data.can_push_to_source_branch; - this.rebaseInProgress = data.rebase_in_progress; - this.approvalsLeft = !data.approved; - this.rebasePath = data.rebase_path; - } - static buildMetrics(metrics) { if (!metrics) { return {}; diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue index e9ab6f5ba7a..beb2ac09992 100644 --- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue @@ -1,7 +1,6 @@ <script> import { GlTooltipDirective } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; -import { pluralize } from '~/lib/utils/text_utility'; import { __, sprintf } from '~/locale'; import { getCommitIconMap } from '~/ide/utils'; @@ -27,11 +26,6 @@ export default { required: false, default: false, }, - forceModifiedIcon: { - type: Boolean, - required: false, - default: false, - }, size: { type: Number, required: false, @@ -45,10 +39,10 @@ export default { }, computed: { changedIcon() { + // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings const suffix = !this.file.changed && this.file.staged && !this.showStagedIcon ? '-solid' : ''; - if (this.forceModifiedIcon) return `file-modified${suffix}`; - return `${getCommitIconMap(this.file).icon}${suffix}`; }, changedIconClass() { @@ -69,7 +63,7 @@ export default { }); } else if (this.file.changed && this.file.staged) { return sprintf(__('Unstaged and staged %{type}'), { - type: pluralize(type), + type, }); } @@ -87,7 +81,7 @@ export default { v-gl-tooltip.right :title="tooltipTitle" :class="{ 'ml-auto': isCentered }" - class="file-changed-icon" + class="file-changed-icon d-inline-block" > <icon v-if="showIcon" :name="changedIcon" :size="size" :css-classes="changedIconClass" /> </span> diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue index a1168fa0f1e..f7c508c4e23 100644 --- a/app/assets/javascripts/vue_shared/components/commit.vue +++ b/app/assets/javascripts/vue_shared/components/commit.vue @@ -1,6 +1,7 @@ <script> import _ from 'underscore'; import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import UserAvatarLink from './user_avatar/user_avatar_link.vue'; import Icon from '../../vue_shared/components/icon.vue'; @@ -129,7 +130,9 @@ export default { * @returns {String} */ userImageAltDescription() { - return this.author && this.author.username ? `${this.author.username}'s avatar` : null; + return this.author && this.author.username + ? sprintf(__("%{username}'s avatar"), { username: this.author.username }) + : null; }, }, }; @@ -149,38 +152,36 @@ export default { :href="mergeRequestRef.path" :title="mergeRequestRef.title" class="ref-name" + >{{ mergeRequestRef.iid }}</gl-link > - {{ mergeRequestRef.iid }} - </gl-link> <gl-link v-else v-gl-tooltip :href="commitRef.ref_url" :title="commitRef.name" class="ref-name" + >{{ commitRef.name }}</gl-link > - {{ commitRef.name }} - </gl-link> </template> <icon name="commit" class="commit-icon js-commit-icon" /> - <gl-link :href="commitUrl" class="commit-sha mr-0"> {{ shortSha }} </gl-link> + <gl-link :href="commitUrl" class="commit-sha mr-0">{{ shortSha }}</gl-link> - <div class="commit-title flex-truncate-parent"> - <tooltip-on-truncate v-if="title" class="flex-truncate-child" :title="title"> + <div class="commit-title"> + <span v-if="title" class="flex-truncate-parent"> <user-avatar-link v-if="hasAuthor" :link-href="author.path" :img-src="author.avatar_url" :img-alt="userImageAltDescription" :tooltip-text="author.username" - class="avatar-image-container" + class="avatar-image-container text-decoration-none" /> - <gl-link :href="commitUrl" class="commit-row-message cgray"> - {{ title }} - </gl-link> - </tooltip-on-truncate> - <span v-else> Can't find HEAD commit for this branch </span> + <tooltip-on-truncate :title="title" class="flex-truncate-child"> + <gl-link :href="commitUrl" class="commit-row-message cgray">{{ title }}</gl-link> + </tooltip-on-truncate> + </span> + <span v-else>{{ __("Can't find HEAD commit for this branch") }}</span> </div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js index ba63683f5c0..da0b45110e2 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js +++ b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js @@ -3,6 +3,7 @@ import { __ } from '~/locale'; const viewers = { image: { id: 'image', + binary: true, }, markdown: { id: 'markdown', diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue index 2ca933a37d2..6a4a834337a 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue @@ -27,7 +27,6 @@ export default { return { width: 0, height: 0, - isLoaded: false, }; }, computed: { @@ -63,8 +62,6 @@ export default { this.height = contentImg.naturalHeight; this.$nextTick(() => { - this.isLoaded = true; - this.$emit('imgLoaded', { width: this.width, height: this.height, @@ -91,7 +88,9 @@ export default { | </template> <template v-if="hasDimensions"> - <strong>W</strong>: {{ width }} | <strong>H</strong>: {{ height }} + <strong>{{ s__('ImageViewerDimensions|W') }}</strong + >: {{ width }} | <strong>{{ s__('ImageViewerDimensions|H') }}</strong + >: {{ height }} </template> </p> </div> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue index 5fdc915fffb..655f0054887 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue @@ -40,7 +40,7 @@ export default { this.fetchMarkdownPreview(); }, destroyed() { - if (this.isLoading) axiosSource.cancel('Cancelling Preview'); + if (this.isLoading) axiosSource.cancel(__('Cancelling Preview')); }, methods: { fetchMarkdownPreview() { diff --git a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue index 36b3ee05456..d5558d93219 100644 --- a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue +++ b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue @@ -1,5 +1,7 @@ <script> /* eslint-disable vue/require-default-prop */ +import { __ } from '~/locale'; + export default { name: 'DeprecatedModal', // use GlModal instead @@ -39,7 +41,7 @@ export default { closeButtonLabel: { type: String, required: false, - default: 'Cancel', + default: __('Cancel'), }, primaryButtonLabel: { type: String, @@ -94,7 +96,7 @@ export default { type="button" class="close float-right" data-dismiss="modal" - aria-label="Close" + :aria-label="__('Close')" @click="emitCancel($event)" > <span aria-hidden="true">×</span> diff --git a/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue b/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue index 7d49c87271d..c35fee84771 100644 --- a/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue @@ -69,7 +69,7 @@ export default { data-display="static" data-toggle="dropdown" > - <icon name="arrow-down" aria-label="toggle dropdown" /> + <icon name="arrow-down" :aria-label="__('toggle dropdown')" /> </button> <ul :class="dropdownClass" class="dropdown-menu dropdown-open-top"> <template v-for="(action, index) in actions"> diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 1bfa91500cb..f49e69c473b 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -131,7 +131,7 @@ export default { </script> <template> - <div> + <div v-if="!file.moved"> <file-header v-if="file.isHeader" :path="file.path" /> <div v-else @@ -146,6 +146,7 @@ export default { <span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated"> <file-icon v-if="!showChangedIcon || file.type === 'tree'" + class="file-row-icon" :file-name="file.name" :loading="file.loading" :folder="isTree" @@ -223,13 +224,8 @@ export default { white-space: nowrap; } -.file-row-name svg { +.file-row-name .file-row-icon { margin-right: 2px; vertical-align: middle; } - -.file-row-name .loading-container { - display: inline-block; - margin-right: 4px; -} </style> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue b/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue index 4e5dfbf3bf8..20bcceeb477 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue @@ -115,7 +115,7 @@ export default { data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" - aria-label="Expand dropdown" + :aria-label="__('Expand dropdown')" > <icon name="angle-down" :size="12" /> </button> @@ -125,7 +125,7 @@ export default { ref="searchInput" v-model="filter" type="search" - placeholder="Filter" + :placeholder="__('Filter')" class="js-filtered-dropdown-input dropdown-input-field" /> <icon class="dropdown-input-search" name="search" /> diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index 3f45dc7853b..c652a684d7c 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -1,5 +1,6 @@ <script> import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; import CiIconBadge from './ci_badge_link.vue'; import TimeagoTooltip from './time_ago_tooltip.vue'; import UserAvatarImage from './user_avatar/user_avatar_image.vue'; @@ -65,7 +66,7 @@ export default { computed: { userAvatarAltText() { - return `${this.user.name}'s avatar`; + return sprintf(__(`%{username}'s avatar`), { username: this.user.name }); }, }, @@ -87,16 +88,12 @@ export default { <strong> {{ itemName }} #{{ itemId }} </strong> - <template v-if="shouldRenderTriggeredLabel"> - triggered - </template> - <template v-else> - created - </template> + <template v-if="shouldRenderTriggeredLabel">{{ __('triggered') }}</template> + <template v-else>{{ __('created') }}</template> <timeago-tooltip :time="time" /> - by + {{ __('by') }} <template v-if="user"> <gl-link diff --git a/app/assets/javascripts/vue_shared/components/icon.vue b/app/assets/javascripts/vue_shared/components/icon.vue index 41c4c861566..fa89473da62 100644 --- a/app/assets/javascripts/vue_shared/components/icon.vue +++ b/app/assets/javascripts/vue_shared/components/icon.vue @@ -1,4 +1,6 @@ <script> +import iconsPath from '@gitlab/svgs/dist/icons.svg'; + // only allow classes in images.scss e.g. s12 const validSizes = [8, 10, 12, 14, 16, 18, 24, 32, 48, 72]; let iconValidator = () => true; @@ -84,7 +86,7 @@ export default { computed: { spriteHref() { - return `${gon.sprite_icons}#${this.name}`; + return `${iconsPath}#${this.name}`; }, iconTestClass() { return `ic-${this.name}`; diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue index e438ff16a41..47f0851f650 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue +++ b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue @@ -1,7 +1,7 @@ <script> import { GlLink } from '@gitlab/ui'; import _ from 'underscore'; -import { sprintf } from '~/locale'; +import { __, sprintf } from '~/locale'; import icon from '../../../vue_shared/components/icon.vue'; function buildDocsLinkStart(path) { @@ -47,7 +47,9 @@ export default { }, confidentialAndLockedDiscussionText() { return sprintf( - 'This issue is %{confidentialLinkStart}confidential%{linkEnd} and %{lockedLinkStart}locked%{linkEnd}.', + __( + 'This issue is %{confidentialLinkStart}confidential%{linkEnd} and %{lockedLinkStart}locked%{linkEnd}.', + ), { confidentialLinkStart: buildDocsLinkStart(this.confidentialIssueDocsPath), lockedLinkStart: buildDocsLinkStart(this.lockedIssueDocsPath), @@ -66,7 +68,7 @@ export default { <span v-if="isLockedAndConfidential"> <span v-html="confidentialAndLockedDiscussionText"></span> {{ - __(`People without permission will never get a notification and won't be able to comment.`) + __("People without permission will never get a notification and won't be able to comment.") }} </span> diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue index eb0f666422f..b76679960ca 100644 --- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue +++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue @@ -160,8 +160,8 @@ export default { :disabled="removeDisabled" type="button" class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button qa-remove-issue-button mr-xl-0 align-self-xl-center" - title="Remove" - aria-label="Remove" + :title="__('Remove')" + :aria-label="__('Remove')" @click="onRemoveRequest" > <icon :size="16" class="btn-item-remove-icon" name="close" /> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 3bdc0bb8ebd..b520d302407 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -1,7 +1,7 @@ <script> import $ from 'jquery'; import _ from 'underscore'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; import { stripHtml } from '~/lib/utils/text_utility'; import Flash from '../../../flash'; import GLForm from '../../../gl_form'; @@ -118,6 +118,18 @@ export default { lineType() { return this.line ? this.line.type : ''; }, + addMultipleToDiscussionWarning() { + return sprintf( + __( + '%{icon}You are about to add %{usersTag} people to the discussion. Proceed with caution.', + ), + { + icon: '<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>', + usersTag: `<strong><span class="js-referenced-users-count">${this.referencedUsers.length}</span></strong>`, + }, + false, + ); + }, }, mounted() { /* @@ -172,7 +184,7 @@ export default { renderMarkdown(data = {}) { this.markdownPreviewLoading = false; - this.markdownPreview = data.body || 'Nothing to preview.'; + this.markdownPreview = data.body || __('Nothing to preview.'); if (data.references) { this.referencedCommands = data.references.commands; @@ -207,7 +219,11 @@ export default { <div v-show="!previewMarkdown" class="md-write-holder"> <div class="zen-backdrop"> <slot name="textarea"></slot> - <a class="zen-control zen-control-leave js-zen-leave" href="#" aria-label="Enter zen mode"> + <a + class="zen-control zen-control-leave js-zen-leave" + href="#" + :aria-label="__('Enter zen mode')" + > <icon :size="32" name="screen-normal" /> </a> <markdown-toolbar @@ -246,13 +262,7 @@ export default { <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 - <strong> - <span class="js-referenced-users-count">{{ referencedUsers.length }}</span> - </strong> - people to the discussion. Proceed with caution. - </span> + <span v-html="addMultipleToDiscussionWarning"></span> </div> </template> </div> diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 56a16c9e4d6..4d27d1c9179 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -38,10 +38,11 @@ export default { computed: { mdTable() { return [ - '| header | header |', + // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 + '| header | header |', // eslint-disable-line @gitlab/i18n/no-non-i18n-strings '| ------ | ------ |', - '| cell | cell |', - '| cell | cell |', + '| cell | cell |', // eslint-disable-line @gitlab/i18n/no-non-i18n-strings + '| cell | cell |', // eslint-disable-line @gitlab/i18n/no-non-i18n-strings ].join('\n'); }, mdSuggestion() { @@ -123,7 +124,7 @@ export default { :cursor-offset="4" :tag-content="lineContent" icon="doc-code" - class="qa-suggestion-btn" + class="qa-suggestion-btn js-suggestion-btn" @click="handleSuggestDismissed" /> <gl-popover diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue index 2eb4ec12a4a..a7cd292e01d 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue @@ -39,7 +39,7 @@ export default { <template> <div class="md-suggestion"> <suggestion-diff-header - class="qa-suggestion-diff-header" + class="qa-suggestion-diff-header js-suggestion-diff-header" :can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled" :is-applied="suggestion.applied" :help-page-path="helpPagePath" diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue index 32783b85df4..12de3671477 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -41,7 +41,7 @@ export default { <template> <div class="md-suggestion-header border-bottom-0 mt-2"> - <div class="qa-suggestion-diff-header font-weight-bold"> + <div class="qa-suggestion-diff-header js-suggestion-diff-header font-weight-bold"> {{ __('Suggested change') }} <a v-if="helpPagePath" :href="helpPagePath" :aria-label="__('Help')" class="js-help-btn"> <icon name="question-o" css-classes="link-highlight" /> @@ -55,7 +55,7 @@ export default { <gl-button v-else-if="canApply" v-gl-tooltip.viewport="__('This also resolves the discussion')" - class="btn-inverted qa-apply-btn" + class="btn-inverted qa-apply-btn js-apply-btn" :disabled="isApplying" variant="success" @click="applySuggestion" diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index 8d3705e1e4a..7f0fcfac071 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -1,5 +1,6 @@ <script> import Vue from 'vue'; +import { __ } from '~/locale'; import SuggestionDiff from './suggestion_diff.vue'; import Flash from '~/flash'; @@ -56,7 +57,7 @@ export default { const suggestionElements = container.querySelectorAll('.js-render-suggestion'); if (this.lineType === 'old') { - Flash('Unable to apply suggestions to a deleted line.', 'alert', this.$el); + Flash(__('Unable to apply suggestions to a deleted line.'), 'alert', this.$el); } suggestionElements.forEach((suggestionEl, i) => { diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index d6c398c8946..5140184eb8e 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -1,4 +1,5 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { GlLink } from '@gitlab/ui'; export default { @@ -33,13 +34,18 @@ export default { <div class="comment-toolbar clearfix"> <div class="toolbar-text"> <template v-if="!hasQuickActionsDocsPath && markdownDocsPath"> - <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1" - >Markdown is supported</gl-link - > + <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1">{{ + __('Markdown is supported') + }}</gl-link> </template> <template v-if="hasQuickActionsDocsPath && markdownDocsPath"> - <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1">Markdown</gl-link> and - <gl-link :href="quickActionsDocsPath" target="_blank" tabindex="-1">quick actions</gl-link> + <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1">{{ + __('Markdown') + }}</gl-link> + and + <gl-link :href="quickActionsDocsPath" target="_blank" tabindex="-1">{{ + __('quick actions') + }}</gl-link> are supported </template> </div> @@ -57,15 +63,17 @@ export default { <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i> </span> <span class="uploading-error-message"></span> - <button class="retry-uploading-link" type="button">Try again</button> or - <button class="attach-new-file markdown-selector" type="button">attach a new file</button> + <button class="retry-uploading-link" type="button">{{ __('Try again') }}</button> or + <button class="attach-new-file markdown-selector" type="button"> + {{ __('attach a new file') }} + </button> </span> <button class="markdown-selector button-attach-file btn-link" tabindex="-1" type="button"> <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i - ><span class="text-attach-file">Attach a file</span> + ><span class="text-attach-file">{{ __('Attach a file') }}</span> </button> <button class="btn btn-default btn-sm hide button-cancel-uploading-files" type="button"> - Cancel + {{ __('Cancel') }} </button> </span> </div> diff --git a/app/assets/javascripts/vue_shared/components/memory_graph.vue b/app/assets/javascripts/vue_shared/components/memory_graph.vue index 16f4ff068f6..26d7d8e8866 100644 --- a/app/assets/javascripts/vue_shared/components/memory_graph.vue +++ b/app/assets/javascripts/vue_shared/components/memory_graph.vue @@ -1,4 +1,5 @@ <script> +import { __, sprintf } from '~/locale'; import { getTimeago } from '../../lib/utils/datetime_utility'; export default { @@ -20,7 +21,7 @@ export default { computed: { getFormattedMedian() { const deployedSince = getTimeago().format(this.deploymentTime * 1000); - return `Deployed ${deployedSince}`; + return sprintf(__('Deployed %{deployedSince}'), { deployedSince }); }, }, mounted() { diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue index baed26a157c..af02b8969ee 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue @@ -39,7 +39,7 @@ export default { </script> <template> - <timeline-entry-item class="note being-posted fade-in-half"> + <timeline-entry-item class="note note-wrapper being-posted fade-in-half"> <div class="timeline-icon"> <user-avatar-link :link-href="getUserData.path" diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index 3c86b7e4c61..d6dfe9eded8 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -103,7 +103,7 @@ export default { <div v-if="hasMoreCommits" class="flex-list"> <div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded"> <icon :name="toggleIcon" :size="8" class="append-right-5" /> - <span>Toggle commit list</span> + <span>{{ __('Toggle commit list') }}</span> </div> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/project_avatar/image.vue b/app/assets/javascripts/vue_shared/components/project_avatar/image.vue index b9311d65360..43bbb756805 100644 --- a/app/assets/javascripts/vue_shared/components/project_avatar/image.vue +++ b/app/assets/javascripts/vue_shared/components/project_avatar/image.vue @@ -14,7 +14,7 @@ /> */ - +import { __ } from '~/locale'; import defaultAvatarUrl from 'images/no_avatar.png'; import { placeholderImage } from '../../../lazy_loader'; @@ -39,7 +39,7 @@ export default { imgAlt: { type: String, required: false, - default: 'project avatar', + default: __('project avatar'), }, size: { type: Number, diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue index df19906309c..f0aae20477b 100644 --- a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue +++ b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue @@ -30,9 +30,16 @@ export default { }, mounted() { + if (window.recaptchaDialogCallback) { + throw new Error('recaptchaDialogCallback is already defined!'); + } window.recaptchaDialogCallback = this.submit.bind(this); }, + beforeDestroy() { + window.recaptchaDialogCallback = null; + }, + methods: { appendRecaptchaScript() { this.removeRecaptchaScript(); diff --git a/app/assets/javascripts/vue_shared/components/select2_select.vue b/app/assets/javascripts/vue_shared/components/select2_select.vue index 6d2612556ff..eb741d238b5 100644 --- a/app/assets/javascripts/vue_shared/components/select2_select.vue +++ b/app/assets/javascripts/vue_shared/components/select2_select.vue @@ -3,6 +3,8 @@ import $ from 'jquery'; import 'select2'; export default { + // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings name: 'Select2Select', props: { options: { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue index b5e43da401e..4dcc121496c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue @@ -85,7 +85,7 @@ export default { @click="toggleSidebar" > <span class="sidebar-collapsed-value"> - <span v-if="showFromText">From</span> <span>{{ dateText('min') }}</span> + <span v-if="showFromText">{{ __('From') }}</span> <span>{{ dateText('min') }}</span> </span> </collapsed-calendar-icon> <div v-if="hasMinAndMaxDates" class="text-center sidebar-collapsed-divider">-</div> @@ -96,7 +96,7 @@ export default { @click="toggleSidebar" > <span class="sidebar-collapsed-value"> - <span v-if="!minDate">Until</span> <span>{{ dateText('max') }}</span> + <span v-if="!minDate">{{ __('Until') }}</span> <span>{{ dateText('max') }}</span> </span> </collapsed-calendar-icon> </div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue index 45f01a6fced..6caf8bc92c2 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue @@ -74,7 +74,7 @@ export default { return dateInWords(this.selectedDate, true); }, collapsedText() { - return this.selectedDateWords ? this.selectedDateWords : 'None'; + return this.selectedDateWords ? this.selectedDateWords : __('None'); }, }, methods: { @@ -112,7 +112,7 @@ export default { class="btn-blank btn-link btn-primary-hover-link btn-sidebar-action" @click="toggleDatePicker" > - Edit + {{ __('Edit') }} </button> <toggle-sidebar v-if="showToggleSidebar" :collapsed="collapsed" @toggle="toggleSidebar" /> </div> @@ -137,11 +137,11 @@ export default { class="btn-blank btn-link btn-secondary-hover-link" @click="newDateSelected(null)" > - remove + {{ __('remove') }} </button> </span> </template> - <span v-else class="no-value"> None </span> + <span v-else class="no-value">{{ __('None') }}</span> </span> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue index 3b5ce0e9910..913c971a512 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue @@ -48,7 +48,7 @@ export default { 'fa-angle-double-right': !collapsed, 'fa-angle-double-left': collapsed, }" - aria-label="toggle collapse" + :aria-label="__('toggle collapse')" class="fa" > </i> diff --git a/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue index 8ba6b73f928..af4eb2de7f8 100644 --- a/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue +++ b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue @@ -78,6 +78,8 @@ export default { return percent; }, barStyle(percent) { + // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings return `width: ${percent}%;`; }, getTooltip(label, count) { 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 a6c1737dcab..ea483416c46 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 @@ -17,6 +17,7 @@ import { GlTooltip } from '@gitlab/ui'; import defaultAvatarUrl from 'images/no_avatar.png'; +import { __ } from '~/locale'; import { placeholderImage } from '../../../lazy_loader'; export default { @@ -43,7 +44,7 @@ export default { imgAlt: { type: String, required: false, - default: 'user avatar', + default: __('user avatar'), }, size: { type: Number, diff --git a/app/assets/javascripts/vue_shared/directives/autofocusonshow.js b/app/assets/javascripts/vue_shared/directives/autofocusonshow.js new file mode 100644 index 00000000000..4659ec20ceb --- /dev/null +++ b/app/assets/javascripts/vue_shared/directives/autofocusonshow.js @@ -0,0 +1,39 @@ +/** + * Input/Textarea Autofocus Directive for Vue + */ +export default { + /** + * Set focus when element is rendered, but + * is not visible, using IntersectionObserver + * + * @param {Element} el Target element + */ + inserted(el) { + if ('IntersectionObserver' in window) { + // Element visibility is dynamic, so we attach observer + el.visibilityObserver = new IntersectionObserver(entries => { + entries.forEach(entry => { + // Combining `intersectionRatio > 0` and + // element's `offsetParent` presence will + // deteremine if element is truely visible + if (entry.intersectionRatio > 0 && entry.target.offsetParent) { + entry.target.focus(); + } + }); + }); + + // Bind the observer. + el.visibilityObserver.observe(el, { root: document.documentElement }); + } + }, + /** + * Detach observer on unbind hook. + * + * @param {Element} el Target element + */ + unbind(el) { + if (el.visibilityObserver) { + el.visibilityObserver.disconnect(); + } + }, +}; diff --git a/app/assets/javascripts/vue_shared/directives/tooltip.js b/app/assets/javascripts/vue_shared/directives/tooltip.js index 2d1f7a1cfd0..73e92728cb9 100644 --- a/app/assets/javascripts/vue_shared/directives/tooltip.js +++ b/app/assets/javascripts/vue_shared/directives/tooltip.js @@ -3,8 +3,12 @@ import '~/commons/bootstrap'; export default { bind(el) { + const glTooltipDelay = localStorage.getItem('gl-tooltip-delay'); + const delay = glTooltipDelay ? JSON.parse(glTooltipDelay) : 0; + $(el).tooltip({ trigger: 'hover', + delay, }); }, diff --git a/app/assets/javascripts/vue_shared/mixins/is_ee.js b/app/assets/javascripts/vue_shared/mixins/is_ee.js deleted file mode 100644 index 8e00d93ef18..00000000000 --- a/app/assets/javascripts/vue_shared/mixins/is_ee.js +++ /dev/null @@ -1,10 +0,0 @@ -import Vue from 'vue'; -import { isEE } from '~/lib/utils/common_utils'; - -Vue.mixin({ - computed: { - isEE() { - return isEE(); - }, - }, -}); diff --git a/app/assets/stylesheets/_ee/application_ee.scss b/app/assets/stylesheets/_ee/application_ee.scss new file mode 100644 index 00000000000..0fb2c9b68a9 --- /dev/null +++ b/app/assets/stylesheets/_ee/application_ee.scss @@ -0,0 +1,5 @@ +/* + This is a noop-file. In EE: + ee/app/assets/stylesheets/_ee/application_ee.scss + will take precedence over it and import more styles + */ diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index a2f518cd24e..e98030f1511 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -11,10 +11,9 @@ // like a table or typography then make changes in the framework/ directory. // If you need to add unique style that should affect only one page - use pages/ // directory. -@import "../../../node_modules/at.js/dist/css/jquery.atwho"; -@import "../../../node_modules/pikaday/scss/pikaday"; -@import "../../../node_modules/dropzone/dist/basic"; -@import "../../../node_modules/select2/select2"; +@import "at.js/dist/css/jquery.atwho"; +@import "dropzone/dist/basic"; +@import "select2/select2"; // GitLab UI framework @import "framework"; @@ -34,4 +33,8 @@ // Styles for JS behaviors. @import "behaviors"; +// EE-only stylesheets +@import "application_ee"; + +// CSS util classes @import "utilities"; diff --git a/app/assets/stylesheets/components/avatar.scss b/app/assets/stylesheets/components/avatar.scss index 8e9650cdf34..312123aeef9 100644 --- a/app/assets/stylesheets/components/avatar.scss +++ b/app/assets/stylesheets/components/avatar.scss @@ -50,6 +50,11 @@ $avatar-sizes: ( line-height: 88px, border-radius: $border-radius-large ), + 96: ( + font-size: 36px, + line-height: 94px, + border-radius: $border-radius-large + ), 100: ( font-size: 36px, line-height: 98px, diff --git a/app/assets/stylesheets/components/popover.scss b/app/assets/stylesheets/components/popover.scss index 8c40c4adb5c..6654553aaa2 100644 --- a/app/assets/stylesheets/components/popover.scss +++ b/app/assets/stylesheets/components/popover.scss @@ -102,6 +102,7 @@ .onboarding-popover { box-shadow: 0 2px 4px $dropdown-shadow-color; + max-width: 280px; .popover-body { font-size: $gl-font-size; diff --git a/app/assets/stylesheets/components/toast.scss b/app/assets/stylesheets/components/toast.scss index acbd909d595..e27bf282247 100644 --- a/app/assets/stylesheets/components/toast.scss +++ b/app/assets/stylesheets/components/toast.scss @@ -15,11 +15,15 @@ .toasted.gl-toast { border-radius: $border-radius-default; font-size: $gl-font-size; - padding: $gl-padding-8 $gl-padding-24; + padding: $gl-padding-8 $gl-padding $gl-padding-8 $gl-padding-24; margin-top: $toast-default-margin; line-height: $gl-line-height; background-color: rgba($gray-900, $toast-background-opacity); + span { + padding-right: $gl-padding-8; + } + @include media-breakpoint-down(xs) { .action:first-of-type { // Ensures actions buttons are right aligned on mobile @@ -29,19 +33,14 @@ .action { color: $blue-300; - margin: 0 0 0 $toast-action-margin-left; + margin: 0 0 0 $toast-default-margin; text-transform: none; font-size: $gl-font-size; - - &:first-of-type { - padding-right: 0; - } } .toast-close { font-size: $default-icon-size; margin-left: $toast-default-margin; - padding-left: $gl-padding; } } } diff --git a/app/assets/stylesheets/csslab.scss b/app/assets/stylesheets/csslab.scss index acaa41e2677..87c59cd42c0 100644 --- a/app/assets/stylesheets/csslab.scss +++ b/app/assets/stylesheets/csslab.scss @@ -1 +1 @@ -@import "../../../node_modules/@gitlab/csslab/dist/css/csslab-slim"; +@import "@gitlab/csslab/dist/css/csslab-slim"; diff --git a/app/assets/stylesheets/errors.scss b/app/assets/stylesheets/errors.scss index 8c32b6c8985..89029a58d1e 100644 --- a/app/assets/stylesheets/errors.scss +++ b/app/assets/stylesheets/errors.scss @@ -2,12 +2,12 @@ * This is a minimal stylesheet, meant to be used for error pages. */ @import 'framework/variables'; -@import '../../../node_modules/bootstrap/scss/functions'; -@import '../../../node_modules/bootstrap/scss/variables'; -@import '../../../node_modules/bootstrap/scss/mixins'; -@import '../../../node_modules/bootstrap/scss/reboot'; -@import '../../../node_modules/bootstrap/scss/buttons'; -@import '../../../node_modules/bootstrap/scss/forms'; +@import 'bootstrap/scss/functions'; +@import 'bootstrap/scss/variables'; +@import 'bootstrap/scss/mixins'; +@import 'bootstrap/scss/reboot'; +@import 'bootstrap/scss/buttons'; +@import 'bootstrap/scss/forms'; $body-color: #666; $header-color: #456; @@ -96,7 +96,7 @@ a { } .error-nav { - padding: 0; + padding: $gl-padding 0 0; text-align: center; li { diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 14f4652e847..82b4ec750ff 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -2,14 +2,13 @@ @import 'framework/variables_overrides'; @import 'framework/mixins'; -@import '../../../node_modules/@gitlab/ui/scss/gitlab_ui'; +@import '@gitlab/ui/scss/gitlab_ui'; @import 'bootstrap_migration'; @import 'framework/layout'; @import 'framework/animations'; @import 'framework/vue_transitions'; -@import 'framework/asciidoctor'; @import 'framework/banner'; @import 'framework/blocks'; @import 'framework/buttons'; diff --git a/app/assets/stylesheets/framework/asciidoctor.scss b/app/assets/stylesheets/framework/asciidoctor.scss deleted file mode 100644 index 1586265d40e..00000000000 --- a/app/assets/stylesheets/framework/asciidoctor.scss +++ /dev/null @@ -1,27 +0,0 @@ -.admonitionblock td.icon { - width: 1%; - - [class^='fa icon-'] { - @extend .fa-2x; - } - - .icon-note { - @extend .fa-thumb-tack; - } - - .icon-tip { - @extend .fa-lightbulb-o; - } - - .icon-warning { - @extend .fa-exclamation-triangle; - } - - .icon-caution { - @extend .fa-fire; - } - - .icon-important { - @extend .fa-exclamation-circle; - } -} diff --git a/app/assets/stylesheets/framework/badges.scss b/app/assets/stylesheets/framework/badges.scss index c6060161dec..c036267a7c8 100644 --- a/app/assets/stylesheets/framework/badges.scss +++ b/app/assets/stylesheets/framework/badges.scss @@ -1,6 +1,6 @@ .badge.badge-pill { font-weight: $gl-font-weight-normal; background-color: $badge-bg; - color: $gl-text-color-secondary; + color: $gray-800; vertical-align: baseline; } diff --git a/app/assets/stylesheets/framework/callout.scss b/app/assets/stylesheets/framework/callout.scss index 643b20c56bc..c5bb2a1256a 100644 --- a/app/assets/stylesheets/framework/callout.scss +++ b/app/assets/stylesheets/framework/callout.scss @@ -9,7 +9,9 @@ .bs-callout { margin: $gl-padding 0; padding: $gl-padding; - border-left: 3px solid $border-color; + border-color: $border-color; + border-style: solid; + border-width: 0 0 0 3px; color: $text-color; background: $gray-light; @@ -48,6 +50,10 @@ background-color: $blue-100; border-color: $blue-200; color: $blue-700; + + h4 { + color: $blue-700; + } } .bs-callout-success { diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 1bd5043ed10..e9218dcec67 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -434,12 +434,16 @@ img.emoji { /** COMMON SIZING CLASSES **/ .w-0 { width: 0; } +.w-8em { width: 8em; } +.w-3rem { width: 3rem; } .h-12em { height: 12em; } +.h-32-px { height: 32px;} .mw-460 { max-width: 460px; } .mw-6em { max-width: 6em; } .mw-70p { max-width: 70%; } +.mw-90p { max-width: 90%; } .min-height-0 { min-height: 0; } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index cd951f67293..29f63e9578d 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -63,7 +63,8 @@ margin-top: 11px; } -.dropdown-toggle { +.dropdown-toggle, +.confidential-merge-request-fork-group .dropdown-toggle { padding: 6px 8px 6px 10px; background-color: $white-light; color: $gl-text-color; diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 26cbb7f5c13..5984efd1cf8 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -9,14 +9,14 @@ float: right; margin-right: 0; - @include media-breakpoint-down(xs) { + @include media-breakpoint-down(sm) { float: none; } } } .filters-section { - @include media-breakpoint-down(xs) { + @include media-breakpoint-down(sm) { display: inline-block; } } @@ -37,7 +37,7 @@ } } -@include media-breakpoint-down(xs) { +@include media-breakpoint-down(sm) { .filter-item { display: block; margin: 0 0 10px; @@ -50,12 +50,6 @@ } .filtered-search-wrapper { - display: flex; - - @include media-breakpoint-down(xs) { - flex-direction: column; - } - .tokens-container { display: flex; flex: 1; @@ -186,7 +180,7 @@ border: 1px solid $border-color; background-color: $white-light; - @include media-breakpoint-down(xs) { + @include media-breakpoint-down(sm) { flex: 1 1 auto; margin-bottom: 10px; } @@ -259,7 +253,7 @@ max-width: 280px; overflow: auto; - @include media-breakpoint-down(xs) { + @include media-breakpoint-down(sm) { width: auto; left: 0; right: 0; @@ -311,7 +305,7 @@ .filtered-search-history-dropdown { width: 40%; - @include media-breakpoint-down(xs) { + @include media-breakpoint-down(sm) { left: 0; right: 0; max-width: none; @@ -341,35 +335,46 @@ } .filter-dropdown-container { - display: flex; - .dropdown-toggle { line-height: 22px; } } -@include media-breakpoint-down(xs) { +@include media-breakpoint-down(sm) { .issues-details-filters { - padding: 0 0 10px; + padding-top: 0; + padding-bottom: 0; background-color: $white-light; border-top: 0; } - .filter-dropdown-container { + .boards-switcher { + margin: 0 0 10px; + + .boards-selector-wrapper, .dropdown { - margin-left: 0; + display: block; } } -} -@include media-breakpoint-down(sm) { - .filter-dropdown-container { - .dropdown-toggle, - .dropdown, - .dropdown-menu { + .filter-dropdown-container > div { + margin: 0; + + > .btn { + margin: 0 0 10px; width: 100%; } } + + .boards-add-list > .btn { + text-align: left; + + > svg { + position: absolute; + top: 11px; + right: 6px; + } + } } .droplab-dropdown .dropdown-menu .filter-dropdown-item { diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index e3dd127366d..96f6d02a68f 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -43,6 +43,7 @@ @extend .alert; background-color: $orange-100; color: $orange-900; + cursor: default; margin: 0; } diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 1bc597bd4ae..ca737c53318 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -131,7 +131,6 @@ > li:not(.d-none) a { @include media-breakpoint-down(xs) { margin-left: 0; - min-width: 100%; } } } @@ -233,7 +232,6 @@ .impersonation-btn, .impersonation-btn:hover { background-color: $white-light; - margin-left: 0; border-top-left-radius: 0; border-bottom-left-radius: 0; diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index 1be5ef276fd..7332c4981d2 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -88,8 +88,5 @@ display: flex; align-items: center; justify-content: center; - border: $border-size solid $gray-400; - border-radius: 50%; - padding: $gl-padding-8 - $border-size; color: $gray-700; } diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index 954551fef97..ecd32dcd0ce 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -265,7 +265,6 @@ ul.controls { } .issuable-pipeline-broken a, - .issuable-pipeline-status a, .author-link { display: flex; } @@ -286,3 +285,19 @@ ul.indent-list { max-width: 350px; } } + +.horizontal-list { + padding-left: 0; + list-style: none; + + > li { + float: left; + } + + &.list-items-separated { + > li:not(:last-child)::after { + content: '\00b7'; + margin: 0 $gl-padding-4; + } + } +} diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index f75e5b55506..fd9a75bc5b6 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -19,23 +19,31 @@ } } - // leave enough space for the close icon .modal-title { + line-height: $gl-line-height-24; + + // leave enough space for the close icon &.mw-100, &.w-100 { - // after upgrading to Bootstrap 4.2 we can use $modal-header-padding-x here - // https://github.com/twbs/bootstrap/pull/26976 - margin-right: -28px; - padding-right: 28px; + margin-right: -$modal-header-padding-x; + padding-right: $modal-header-padding-x; } } + + .close { + font-weight: $gl-font-weight-normal; + line-height: $gl-line-height; + color: $gray-900; + opacity: 1; + } } .modal-body { background-color: $modal-body-bg; line-height: $line-height-base; position: relative; - padding: #{3 * $grid-size} #{2 * $grid-size}; + min-height: $modal-body-height; + padding: #{2 * $grid-size} #{6 * $grid-size} #{2 * $grid-size} #{2 * $grid-size}; text-align: left; white-space: normal; @@ -52,12 +60,20 @@ display: flex; flex-direction: row; + .btn { + margin: 0; + } + .btn + .btn:not(.dropdown-toggle-split), .btn + .btn-group, .btn-group + .btn { margin-left: $grid-size; } + .btn-group .btn + .btn { + margin-left: -1px; + } + @include media-breakpoint-down(xs) { flex-direction: column; @@ -67,6 +83,11 @@ margin-left: 0; margin-top: $grid-size; } + + .btn-group .btn + .btn { + margin-left: -1px; + margin-top: 0; + } } } @@ -85,9 +106,23 @@ body.modal-open { .modal { background-color: $black-transparent; - @include media-breakpoint-up(md) { + .modal-content { + border-radius: $modal-border-radius; + + > :first-child { + border-top-left-radius: $modal-border-radius; + border-top-right-radius: $modal-border-radius; + } + + > :last-child { + border-bottom-left-radius: $modal-border-radius; + border-bottom-right-radius: $modal-border-radius; + } + } + + @include media-breakpoint-up(sm) { .modal-dialog { - margin: 30px auto; + margin: 64px auto; } } } diff --git a/app/assets/stylesheets/framework/panels.scss b/app/assets/stylesheets/framework/panels.scss index cd3d6f8297e..d9c93fed1c4 100644 --- a/app/assets/stylesheets/framework/panels.scss +++ b/app/assets/stylesheets/framework/panels.scss @@ -3,7 +3,6 @@ } .card-slim { - @extend .card; margin-bottom: $gl-vert-padding; } diff --git a/app/assets/stylesheets/framework/responsive_tables.scss b/app/assets/stylesheets/framework/responsive_tables.scss index 6bd44ee19bd..fd6f80e26cb 100644 --- a/app/assets/stylesheets/framework/responsive_tables.scss +++ b/app/assets/stylesheets/framework/responsive_tables.scss @@ -155,7 +155,7 @@ text-overflow: ellipsis; @include media-breakpoint-up(md) { - flex: 0 0 90%; + flex: 0 0 85%; } .avatar { diff --git a/app/assets/stylesheets/framework/tooltips.scss b/app/assets/stylesheets/framework/tooltips.scss index 98f28987a82..edc2fb532c8 100644 --- a/app/assets/stylesheets/framework/tooltips.scss +++ b/app/assets/stylesheets/framework/tooltips.scss @@ -1,7 +1,6 @@ .tooltip-inner { - font-size: $tooltip-font-size; + font-size: $gl-font-size-small; border-radius: $border-radius-default; - line-height: 16px; + line-height: $gl-line-height; font-weight: $gl-font-weight-normal; - padding: 8px; } diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 7baab478034..c201605e83d 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -1,5 +1,5 @@ /** - * Apply Markdown typography + * Apply Markup (Markdown/AsciiDoc) typography * */ .md:not(.use-csslab) { @@ -245,6 +245,21 @@ } } + ul.checklist, + ul.none, + ol.none, + ul.no-bullet, + ol.no-bullet, + ol.unnumbered, + ul.unstyled, + ol.unstyled { + list-style-type: none; + + li { + margin-left: 0; + } + } + li { line-height: 1.6em; margin-left: 25px; @@ -321,6 +336,54 @@ visibility: visible; } } + + .big { + font-size: larger; + } + + .small { + font-size: smaller; + } + + .underline { + text-decoration: underline; + } + + .overline { + text-decoration: overline; + } + + .line-through { + text-decoration: line-through; + } + + .admonitionblock td.icon { + width: 1%; + + [class^='fa icon-'] { + @extend .fa-2x; + } + + .icon-note { + @extend .fa-thumb-tack; + } + + .icon-tip { + @extend .fa-lightbulb-o; + } + + .icon-warning { + @extend .fa-exclamation-triangle; + } + + .icon-caution { + @extend .fa-fire; + } + + .icon-important { + @extend .fa-exclamation-circle; + } + } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index b6a24247d40..9871771542d 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -469,6 +469,7 @@ $link-active-background: rgba(0, 0, 0, 0.04); $link-hover-background: rgba(0, 0, 0, 0.06); $inactive-badge-background: rgba(0, 0, 0, 0.08); $sidebar-toggle-height: 60px; +$sidebar-toggle-width: 40px; $sidebar-milestone-toggle-bottom-margin: 10px; /* @@ -507,7 +508,6 @@ $toast-height: 48px; $toast-max-width: 586px; $toast-padding-right: 42px; $toast-default-margin: 8px; -$toast-action-margin-left: 16px; $toast-background-opacity: 0.95; /* @@ -604,6 +604,7 @@ $blame-blue: #254e77; * Builds */ $builds-trace-bg: #111; +$job-log-highlight-height: 18px; /* * Commit Page @@ -805,8 +806,9 @@ $border-color-settings: #e1e1e1; /* Modals */ -$modal-body-height: 134px; +$modal-body-height: 80px; $modal-border-color: #e9ecef; +$modal-border-radius: 0.25rem; $priority-label-empty-state-width: 114px; diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss index ea96381a098..604b48e11ab 100644 --- a/app/assets/stylesheets/framework/variables_overrides.scss +++ b/app/assets/stylesheets/framework/variables_overrides.scss @@ -48,3 +48,7 @@ $spacers: ( 9: ($spacer * 8) ); $pagination-color: $gl-text-color; +$tooltip-padding-y: 0.5rem; +$tooltip-padding-x: 0.75rem; +$tooltip-arrow-height: 0.5rem; +$tooltip-arrow-width: 1rem; diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss index ac3214a07d9..bdeac7e97c0 100644 --- a/app/assets/stylesheets/highlight/common.scss +++ b/app/assets/stylesheets/highlight/common.scss @@ -16,3 +16,16 @@ color: $dark-diff-match-bg; background: $dark-diff-match-color; } + +@mixin diff-expansion($background, $border, $link) { + background-color: $background; + + td { + border-top: 1px solid $border; + border-bottom: 1px solid $border; + } + + a { + color: $link; + } +} diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss index 16893dd047e..cbce0ba3f1e 100644 --- a/app/assets/stylesheets/highlight/themes/dark.scss +++ b/app/assets/stylesheets/highlight/themes/dark.scss @@ -111,6 +111,10 @@ $dark-il: #de935f; color: $dark-line-color; } + .line_expansion { + @include diff-expansion($dark-main-bg, $dark-border, $dark-na); + } + // Diff line .line_holder { &.match .line_content, diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss index 37fe61b925c..1b61ffa37e3 100644 --- a/app/assets/stylesheets/highlight/themes/monokai.scss +++ b/app/assets/stylesheets/highlight/themes/monokai.scss @@ -111,6 +111,10 @@ $monokai-gi: #a6e22e; color: $monokai-text-color; } + .line_expansion { + @include diff-expansion($monokai-bg, $monokai-border, $monokai-k); + } + // Diff line .line_holder { &.match .line_content, diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss index b4217aac37a..a7ede266fb5 100644 --- a/app/assets/stylesheets/highlight/themes/none.scss +++ b/app/assets/stylesheets/highlight/themes/none.scss @@ -34,8 +34,11 @@ color: $gl-text-color; } -// Diff line + .line_expansion { + @include diff-expansion($gray-light, $white-normal, $gl-text-color); + } + // Diff line $none-over-bg: #ded7fc; $none-expanded-border: #e0e0e0; $none-expanded-bg: #e0e0e0; diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss index a4e9eda22c9..6569f3abc8b 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss @@ -115,6 +115,10 @@ $solarized-dark-il: #2aa198; color: $solarized-dark-pre-color; } + .line_expansion { + @include diff-expansion($solarized-dark-line-bg, $solarized-dark-border, $solarized-dark-kd); + } + // Diff line .line_holder { &.match .line_content, diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss index b604d1ccb6c..4e74a9ea50a 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-light.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss @@ -122,6 +122,10 @@ $solarized-light-il: #2aa198; color: $solarized-light-pre-color; } + .line_expansion { + @include diff-expansion($solarized-light-line-bg, $solarized-light-border, $solarized-light-kd); + } + // Diff line .line_holder { &.match .line_content, diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss index ee0ec94c636..973f94c63aa 100644 --- a/app/assets/stylesheets/highlight/white_base.scss +++ b/app/assets/stylesheets/highlight/white_base.scss @@ -101,6 +101,10 @@ pre.code, color: $white-code-color; } +.line_expansion { + @include diff-expansion($gray-light, $border-color, $blue-600); +} + // Diff line .line_holder { &.match .line_content, diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index cbcd8a474f1..ba126d59eef 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -1216,11 +1216,10 @@ $ide-commit-header-height: 48px; } .ide-search-list-empty { - height: 230px; + height: 69px; } .ide-merge-requests-dropdown-content { - min-height: 230px; max-height: 470px; } diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 5e3652db48f..e77a2d1e333 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -86,15 +86,29 @@ } .board { + // the next line cannot be replaced with .d-inline-block because it breaks display: none of SortableJS + // see https://gitlab.com/gitlab-org/gitlab-ce/issues/64828 + display: inline-block; width: calc(85vw - 15px); @include media-breakpoint-up(sm) { width: 400px; } - &.is-expandable { - .board-header { - cursor: pointer; + .board-title-caret { + cursor: pointer; + border-radius: $border-radius-default; + padding: 4px; + + &:hover { + background-color: $gray-dark; + transition: background-color 0.1s linear; + } + } + + &:not(.is-collapsed) { + .board-title-caret { + margin: 0 $gl-padding-4 0 -10px; } } @@ -102,20 +116,51 @@ width: 50px; .board-title { - > span { - width: 100%; - margin-top: -12px; + flex-direction: column; + height: 100%; + padding: $gl-padding-8 0; + } + + .board-title-caret { + margin-top: 1px; + } + + .user-avatar-link, + .milestone-icon { + margin-top: $gl-padding-8; + transform: rotate(90deg); + } + + .board-title-text { + flex-grow: 0; + margin: $gl-padding-8 0; + + .board-title-main-text { display: block; - transform: rotate(90deg) translate(35px, 0); - overflow: initial; + } + + .board-title-sub-text { + display: none; } } - .board-title-expandable-toggle { - position: absolute; - top: 50%; - left: 50%; - margin-left: -10px; + .issue-count-badge { + border: 0; + white-space: nowrap; + } + + .board-title-text > span, + .issue-count-badge > span { + height: 16px; + + // Force the height to be equal to the parent's width while centering the contents. + // The contents *should* be about 16 px. + // We do this because the flow of elements isn't affected by the rotate transform, so we must ensure that a + // rotated element has square dimensions so it won't overlap with its siblings. + margin: calc(50% - 8px) 0; + + transform: rotate(90deg); + transform-origin: center; } } } @@ -152,12 +197,14 @@ } .board-title { + align-items: center; font-size: 1em; border-bottom: 1px solid $border-color; + padding: $gl-padding-8 $gl-padding; } .board-title-text { - margin: $gl-vert-padding auto $gl-vert-padding 0; + flex-grow: 1; } .board-delete { diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 6e98908eeed..73166940146 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -125,8 +125,24 @@ padding-left: $gl-padding-8; } - .section-header ~ .section.line { - margin-left: $gl-padding; + .section-start { + display: inline; + } + + .section-start, + .section-header { + &:hover { + cursor: pointer; + + &::after { + content: ''; + background-color: rgba($white-light, 0.2); + left: 0; + right: 0; + position: absolute; + height: $job-log-highlight-height; + } + } } } diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index e12ea6fcb99..0b0a4e50146 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -167,7 +167,7 @@ min-width: 0; .project-namespace { - color: $gl-text-color-secondary; + color: $gl-text-color-tertiary; } } diff --git a/app/assets/stylesheets/pages/container_registry.scss b/app/assets/stylesheets/pages/container_registry.scss index dfff3e15556..b88bd78cf3d 100644 --- a/app/assets/stylesheets/pages/container_registry.scss +++ b/app/assets/stylesheets/pages/container_registry.scss @@ -2,6 +2,12 @@ * Container Registry */ +.container-message { + span .btn { + margin: 0; + } +} + .container-image { border-bottom: 1px solid $white-normal; } @@ -21,4 +27,21 @@ .table.tags { margin-bottom: 0; + + .registry-image-row { + .check { + padding-right: $gl-padding; + width: 5%; + } + + .action-buttons { + opacity: 0; + } + + &:hover { + .action-buttons { + opacity: 1; + } + } + } } diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index 2b932d164a5..d80155a416d 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -51,27 +51,19 @@ } .stage-header { - width: 26%; - padding-left: $gl-padding; + width: 18.5%; } .median-header { - width: 14%; + width: 21.5%; } .event-header { width: 45%; - padding-left: $gl-padding; } .total-time-header { width: 15%; - text-align: right; - padding-right: $gl-padding; - } - - .stage-name { - font-weight: $gl-font-weight-bold; } } @@ -153,23 +145,13 @@ } .stage-nav-item { - display: flex; line-height: 65px; - border-top: 1px solid transparent; - border-bottom: 1px solid transparent; - border-right: 1px solid $border-color; - background-color: $gray-light; + border: 1px solid $border-color; &.active { - background-color: transparent; - border-right-color: transparent; - border-top-color: $border-color; - border-bottom-color: $border-color; - box-shadow: inset 2px 0 0 0 $blue-500; - - .stage-name { - font-weight: $gl-font-weight-bold; - } + background: $blue-50; + border-color: $blue-300; + box-shadow: inset 4px 0 0 0 $blue-500; } &:hover:not(.active) { @@ -178,24 +160,12 @@ cursor: pointer; } - &:first-child { - border-top: 0; - } - - &:last-child { - border-bottom: 0; - } - - .stage-nav-item-cell { - &.stage-median { - margin-left: auto; - margin-right: $gl-padding; - min-width: calc(35% - #{$gl-padding}); - } + .stage-nav-item-cell.stage-name { + width: 44.5%; } - .stage-name { - padding-left: 16px; + .stage-nav-item-cell.stage-median { + min-width: 43%; } .stage-empty, diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index d2d35d91e0b..77a2fd6b876 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -14,7 +14,7 @@ position: -webkit-sticky; position: sticky; top: $mr-file-header-top; - z-index: 220; + z-index: 120; &::before { content: ''; @@ -408,6 +408,14 @@ table.code { table-layout: fixed; border-radius: 0 0 $border-radius-default $border-radius-default; + tr:first-of-type.line_expansion > td { + border-top: 0; + } + + tr:nth-last-of-type(2).line_expansion > td { + border-bottom: 0; + } + tr.line_holder td { line-height: $code-line-height; font-size: $code-font-size; @@ -1024,7 +1032,6 @@ table.code { $top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px; top: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px; max-height: calc(100vh - #{$top-pos}); - padding-right: $gl-padding; z-index: 202; .with-performance-bar & { @@ -1035,7 +1042,7 @@ table.code { .drag-handle { bottom: 16px; - transform: translateX(-6px); + transform: translateX(10px); } } @@ -1093,6 +1100,21 @@ table.code { line-height: 0; } +.discussion-collapsible { + margin: 0 $gl-padding $gl-padding 71px; + + .notes { + border-radius: $border-radius-default; + } +} + +.parallel { + .discussion-collapsible { + margin: $gl-padding; + margin-top: 0; + } +} + @media (max-width: map-get($grid-breakpoints, md)-1) { .diffs .files { @include fixed-width-container; @@ -1110,6 +1132,11 @@ table.code { padding-right: 0; } } + + .discussion-collapsible { + margin: $gl-padding; + margin-top: 0; + } } .image-diff-overlay, diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index cff2e274390..1502cf18440 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -412,10 +412,6 @@ table.pipeline-project-metrics tr td { font-size: $gl-font-size-large; } - .item-visibility { - color: $gl-text-color-secondary; - } - @include media-breakpoint-down(md) { .title { font-size: $gl-font-size; diff --git a/app/assets/stylesheets/pages/help.scss b/app/assets/stylesheets/pages/help.scss index 7610c5cf6f3..ef872e693e0 100644 --- a/app/assets/stylesheets/pages/help.scss +++ b/app/assets/stylesheets/pages/help.scss @@ -38,3 +38,9 @@ .documentation { padding: 7px; } + +.card.links-card { + a { + color: $blue-600; + } +} diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 6a0127eb51c..0e844b0e4a5 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -126,6 +126,16 @@ } } +.assignee { + .merge-icon { + color: $orange-500; + position: absolute; + bottom: 0; + right: 0; + text-shadow: -1px -1px 0 $white-light, 1px -1px 0 $white-light, -1px 1px 0 $white-light, 1px 1px 0 $white-light; + } +} + .right-sidebar { position: fixed; top: $header-height; @@ -202,7 +212,6 @@ &.assignee { .author-link { display: block; - padding-left: 42px; position: relative; &:hover { @@ -210,12 +219,6 @@ text-decoration: underline; } } - - .avatar { - left: 0; - position: absolute; - top: 0; - } } } } @@ -354,13 +357,6 @@ margin-top: 0; } - .assignee .avatar { - float: left; - margin-right: 10px; - margin-bottom: 0; - margin-left: 0; - } - .assignee .user-list .avatar { margin: 0; } @@ -390,7 +386,7 @@ .block { width: $gutter-collapsed-width - 2px; - padding: 15px 0 0; + padding: 0; border-bottom: 0; overflow: hidden; @@ -427,10 +423,13 @@ } .sidebar-collapsed-icon { - display: block; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; width: 100%; + height: $sidebar-toggle-height; text-align: center; - margin-bottom: 10px; color: $gl-text-color-secondary; svg { @@ -470,6 +469,16 @@ } .btn-clipboard { + /* + This change should be temporary, because the DOM currently gets + generated from a ruby definition in `app/helpers/button_helper.rb`. + As soon as the `copy to clipboard` button will be transfered to + Vue this should be adjusted as well. + */ + flex: 1; + align-self: stretch; + padding: 0; + border: 0; background: transparent; color: $gl-text-color-secondary; @@ -493,7 +502,6 @@ .sidebar-collapsed-user { padding-bottom: 0; - margin-bottom: 10px; .author-link { padding-left: 0; @@ -509,7 +517,12 @@ display: none; } + .merge-icon { + font-size: 10px; + } + .multiple-users { + position: relative; height: 24px; margin-bottom: 17px; margin-top: 4px; diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 11e8a32389f..7d5e185834b 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -30,6 +30,10 @@ .dropdown-content { max-height: 135px; } + + .dropdown-label-box { + flex: 0 0 auto; + } } .dropdown-new-label { diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index 68af01f9ccc..ae92a2fbd7b 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -9,10 +9,6 @@ } } -.member-sort-dropdown { - margin-left: $gl-padding-8; -} - .member { &.is-overridden { .btn-ldap-override { @@ -62,43 +58,11 @@ } } -.member-search-form { - position: relative; - - @include media-breakpoint-up(sm) { - float: right; - } - - .dropdown { - width: 100%; - margin-top: 5px; - - .dropdown-menu-toggle { - vertical-align: middle; - width: 100%; - } - - @include media-breakpoint-up(sm) { - margin-top: 0; - width: 155px; - } - } - - .form-control { - width: 100%; - padding-right: 35px; - - @include media-breakpoint-up(sm) { - width: 250px; - } - } -} - .member-search-btn { position: absolute; right: 4px; top: 0; - height: 35px; + height: $input-height; padding-left: 10px; padding-right: 10px; color: $gray-darkest; @@ -177,7 +141,7 @@ padding-bottom: 1px; } - .flex-project-members-form { + .flex-users-form { flex-wrap: nowrap; white-space: nowrap; margin-left: auto; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 3917937f4af..c8d155706a9 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -10,8 +10,8 @@ float: left; } - > *:not(:last-child) { - margin-right: 10px; + > *:not(:first-child) { + margin-left: 10px; } } @@ -69,7 +69,7 @@ content: ''; border-left: 1px solid $gray-200; position: absolute; - left: 32px; + left: 28px; top: -17px; height: 16px; } @@ -88,7 +88,7 @@ } .mr-widget-info { - padding-left: $gl-padding-50 - $gl-padding-32; + padding-left: $gl-padding; padding-right: $gl-padding; } @@ -114,7 +114,7 @@ padding: $gl-padding; @include media-breakpoint-up(md) { - padding-left: $gl-padding-50; + padding-left: $gl-padding-8 * 7; } } } @@ -208,6 +208,10 @@ font-size: 22px; } + .mr-loading-icon { + margin: 3px 0; + } + .ci-status-icon svg { margin: 3px 0; position: relative; @@ -262,19 +266,11 @@ } } - .widget-status-icon { - align-self: flex-start; - } - .mr-widget-body { line-height: 28px; @include clearfix; - &.media > *:first-child { - margin-right: 10px; - } - .approve-btn { margin-right: 5px; } @@ -312,6 +308,7 @@ .bold { font-weight: $gl-font-weight-bold; color: $gl-gray-light; + margin-left: 10px; } .state-label { @@ -377,9 +374,13 @@ &.mr-widget-empty-state { line-height: 20px; + padding: $gl-padding; .artwork { - margin-bottom: $gl-padding; + + @include media-breakpoint-down(md) { + margin-bottom: $gl-padding; + } } .text { @@ -395,8 +396,7 @@ } .mr-widget-help { - padding: 10px 16px 10px $gl-padding-50; - font-style: italic; + padding: 10px 16px 10px ($gl-padding-8 * 7); } .ci-coverage { @@ -518,7 +518,7 @@ } .mr-links { - padding-left: $status-icon-size + $gl-btn-padding; + padding-left: $gl-padding-8 + $status-icon-size + $gl-btn-padding; &:last-child { padding-bottom: $gl-padding; @@ -905,7 +905,7 @@ } .deploy-heading, -.merge-train-info { +.merge-train-position-indicator { @include media-breakpoint-up(md) { padding: $gl-padding-8 $gl-padding; } @@ -913,7 +913,7 @@ .media-body { min-width: 0; font-size: 12px; - margin-left: 48px; + margin-left: 40px; } &:not(:last-child) { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index e880b941d67..6c03dbb56a7 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -134,6 +134,15 @@ $note-form-margin-left: 72px; } } + .discussion-toggle-replies { + border-top: 0; + border-radius: 4px 4px 0 0; + + &.collapsed { + border-radius: 4px; + } + } + .note-created-ago, .note-updated-at { white-space: normal; @@ -396,7 +405,7 @@ $note-form-margin-left: 72px; border-radius: 0; @media (min-width: map-get($grid-breakpoints, md)) { - top: 91px; + top: $mr-tabs-height + $header-height; .with-performance-bar & { top: 126px; @@ -462,6 +471,14 @@ $note-form-margin-left: 72px; position: relative; } + .notes-content .discussion-notes.diff-discussions { + border-bottom: 1px solid $border-color; + + &:nth-last-child(1) { + border-bottom: 0; + } + } + .notes_holder { font-family: $regular-font; @@ -517,6 +534,17 @@ $note-form-margin-left: 72px; .discussion-reply-holder { border-radius: 0 0 $border-radius-default $border-radius-default; position: relative; + + .discussion-form { + width: 100%; + background-color: $gray-light; + padding: 0; + } + + .disabled-comment { + padding: $gl-vert-padding 0; + width: 100%; + } } } @@ -569,7 +597,8 @@ $note-form-margin-left: 72px; } .discussion-header { - min-height: 74px; + min-height: $line-height-base * 2em; + box-sizing: content-box; .note-header-info { padding-bottom: 0; @@ -579,13 +608,10 @@ $note-form-margin-left: 72px; overflow-x: auto; overflow-y: hidden; } -} -.unresolved { - .discussion-header { - .note-header-info { - margin-top: $gl-padding-8; - } + &.note-wrapper { + display: flex; + align-items: center; } } @@ -780,7 +806,7 @@ $note-form-margin-left: 72px; border-radius: $border-radius-base; border: 1px solid $border-gray-normal; color: $note-disabled-comment-color; - padding: 90px 0; + padding: $gl-padding-8 0; &.discussion-locked { border: 0; diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index aa6bbc8e473..5f4db37c317 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -588,8 +588,8 @@ } .ci-status-icon svg { - height: 20px; - width: 20px; + height: 24px; + width: 24px; } .dropdown-menu-toggle { @@ -695,6 +695,10 @@ top: -1px; } + .spinner { + top: 2px; + } + &.play { svg { left: 2px; @@ -861,6 +865,7 @@ button.mini-pipeline-graph-dropdown-toggle { } } + .spinner, svg { width: $ci-action-dropdown-svg-size; height: $ci-action-dropdown-svg-size; diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss index 2d600e3aef6..72f1b5307ec 100644 --- a/app/assets/stylesheets/pages/prometheus.scss +++ b/app/assets/stylesheets/pages/prometheus.scss @@ -1,17 +1,17 @@ .prometheus-graphs { - .dropdowns { - .dropdown-menu-toggle { - svg { - position: absolute; - right: 5%; - top: 25%; - } + .dropdown-buttons { + > div { + margin-left: auto; } + } - .dropdown-menu-toggle, - .dropdown-menu { - width: 240px; - } + .col-form-label { + line-height: 1; + padding-top: 0; + } + + .form-group { + margin-bottom: map-get($spacing-scale, 3); } } @@ -29,6 +29,11 @@ padding: $gl-padding / 2; } +.prometheus-graph-embed { + border: 1px solid $border-color; + border-radius: $border-radius-default; +} + .prometheus-graph-header { display: flex; align-items: center; diff --git a/app/assets/stylesheets/pages/reports.scss b/app/assets/stylesheets/pages/reports.scss index 94da72622af..0fbf7033aa5 100644 --- a/app/assets/stylesheets/pages/reports.scss +++ b/app/assets/stylesheets/pages/reports.scss @@ -48,16 +48,11 @@ padding: $gl-padding-top $gl-padding; border-top: 1px solid $border-color; } - - .report-block-list-icon .loading-container { - position: relative; - left: -2px; - } } .report-block-container { border-top: 1px solid $border-color; - padding: $gl-padding-top; + padding: $gl-padding - 2; background-color: $gray-light; // Clean MR widget CSS @@ -96,17 +91,14 @@ .ci-status-icon { svg { - width: 16px; - height: 16px; - left: -2px; + width: 24px; + height: 24px; } } } .report-block-list-issue { display: flex; - align-items: flex-start; - align-content: flex-start; } .is-dismissed .report-block-list-issue-description, diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index dbf600df9d6..58e46cfb70f 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -186,15 +186,12 @@ input[type='checkbox']:hover { } } -.search-holder { - @include media-breakpoint-up(sm) { - display: flex; - } +.search-field-holder, +.project-filter-form { + flex: 1 0 auto; + position: relative; - .search-field-holder, - .project-filter-form { - flex: 1 0 auto; - position: relative; + .search-holder & { margin-right: 0; @include media-breakpoint-up(sm) { @@ -202,6 +199,7 @@ input[type='checkbox']:hover { } } + .search-icon { position: absolute; left: 10px; @@ -215,9 +213,16 @@ input[type='checkbox']:hover { padding-left: $gl-padding + 15px; padding-right: $gl-padding + 15px; } +} + +.search-holder { + @include media-breakpoint-up(sm) { + display: flex; + } .btn-search, - .btn-success { + .btn-success, + .dropdown-menu-toggle { width: 100%; margin-top: 5px; @@ -236,9 +241,6 @@ input[type='checkbox']:hover { } .dropdown-menu-toggle { - width: 100%; - margin-top: 5px; - @include media-breakpoint-up(sm) { width: 180px; margin-top: 0; @@ -262,6 +264,25 @@ input[type='checkbox']:hover { } } +.search-page-form { + .dropdown-menu-toggle, + .btn-search { + width: 100%; + } + + .dropdown-menu-toggle { + @include media-breakpoint-up(lg) { + width: 240px; + } + } + + .btn-search { + @include media-breakpoint-up(lg) { + width: auto; + } + } +} + // Disable webkit input icons, link to solution: https://stackoverflow.com/questions/9421551/how-do-i-remove-all-default-webkit-search-field-styling /* stylelint-disable property-no-vendor-prefix */ input[type='search']::-webkit-search-decoration, diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 3b62121eb0d..79de1d78a6e 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -272,7 +272,7 @@ } .custom-monitored-metrics { - .card-title { + .card-header { display: flex; align-items: center; @@ -292,17 +292,6 @@ } } - .loading-metrics, - .empty-metrics { - padding: 30px 10px; - - p, - .btn { - margin-top: 10px; - margin-bottom: 0; - } - } - .loading-metrics .metrics-load-spinner { color: $gl-gray-700; } diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss index 586365eb1ce..7b64c67ae34 100644 --- a/app/assets/stylesheets/pages/todos.scss +++ b/app/assets/stylesheets/pages/todos.scss @@ -4,7 +4,7 @@ */ .todos-list > .todo { - // workaround because we cannot use border-colapse + // workaround because we cannot use border-collapse border-top: 1px solid transparent; display: flex; flex-direction: row; diff --git a/app/assets/stylesheets/pages/users.scss b/app/assets/stylesheets/pages/users.scss new file mode 100644 index 00000000000..3b018c1e087 --- /dev/null +++ b/app/assets/stylesheets/pages/users.scss @@ -0,0 +1,105 @@ +.user-sort-dropdown { + margin-left: $gl-padding-8; +} + +.user-search-form { + position: relative; + + @include media-breakpoint-up(sm) { + float: right; + } + + .dropdown { + width: 100%; + margin-top: 5px; + + .dropdown-menu-toggle { + vertical-align: middle; + width: 100%; + } + + @include media-breakpoint-up(sm) { + margin-top: 0; + width: 155px; + } + } + + .form-control { + width: 100%; + padding-right: 35px; + + @include media-breakpoint-up(sm) { + width: 250px; + } + } +} + +.user-search-btn { + position: absolute; + right: 4px; + top: 0; + height: 35px; + padding-left: 10px; + padding-right: 10px; + color: $gray-darkest; + background: transparent; + border: 0; + outline: 0; +} + +.flex-users-panel { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + + @include media-breakpoint-down(sm) { + display: block; + + .flex-project-title { + vertical-align: top; + display: inline-block; + max-width: 90%; + } + } + + .flex-project-title { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .badge.badge-pill { + height: 17px; + line-height: 16px; + margin-right: 5px; + padding-top: 1px; + padding-bottom: 1px; + } + + .flex-users-form { + flex-wrap: nowrap; + white-space: nowrap; + margin-left: auto; + } +} + +.content-list.members-list li { + display: flex; + justify-content: space-between; + + .list-item-name { + float: none; + display: flex; + flex: 1; + } +} + +.card-body .user-info { + float: left; + + .user { + color: $gl-text-color; + font-weight: $gl-font-weight-bold; + } +} diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss index 10120a472d3..0b65b915abf 100644 --- a/app/assets/stylesheets/pages/wiki.scss +++ b/app/assets/stylesheets/pages/wiki.scss @@ -1,19 +1,3 @@ -.new-wiki-page { - .new-wiki-page-slug-tip { - display: inline-block; - max-width: 100%; - margin-top: 5px; - } -} - -.wiki-form { - .edit-wiki-page-slug-tip { - display: inline-block; - max-width: 100%; - margin-top: 5px; - } -} - .title .edit-wiki-header { width: 780px; margin-left: auto; @@ -22,7 +6,6 @@ } .wiki-page-header { - @extend .top-area; position: relative; .wiki-breadcrumb { @@ -49,13 +32,11 @@ color: $gl-text-color-secondary; } - .git-access-header { - padding: $gl-padding 0 $gl-padding-top; - } - .git-clone-holder { - width: 100%; - padding-bottom: 40px; + .input-group-prepend, + .input-group-append { + background-color: transparent; + } } button.sidebar-toggle { @@ -65,19 +46,8 @@ display: block; } - @include media-breakpoint-up(sm) { - &.has-sidebar-toggle { - padding-right: 40px; - } - - .git-clone-holder { - width: 480px; - padding-bottom: $gl-padding; - } - - .nav-controls { - width: auto; - } + &.has-sidebar-toggle .git-access-header { + padding-right: $sidebar-toggle-width; } @include media-breakpoint-up(md) { @@ -122,10 +92,6 @@ padding: 0 $gl-padding; } - .block { - width: 100%; - } - a { color: $layout-link-gray; @@ -168,6 +134,10 @@ } ul.wiki-pages-list.content-list { + a { + color: $blue-600; + } + ul { list-style: none; margin-left: 0; diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss index 5a8940ffd6d..ad7d87f0bf6 100644 --- a/app/assets/stylesheets/performance_bar.scss +++ b/app/assets/stylesheets/performance_bar.scss @@ -1,6 +1,5 @@ @import 'framework/variables'; @import 'framework/variables_overrides'; -@import 'peek/views/rblineprof'; #js-peek { position: fixed; @@ -128,13 +127,3 @@ #modal-peek-pg-queries-content { color: $black; } - -.peek-rblineprof-file { - pre.duration { - width: 280px; - } - - .data { - overflow: visible; - } -} |