diff options
Diffstat (limited to 'app/assets/javascripts')
267 files changed, 4882 insertions, 2129 deletions
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 a649c521405..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', @@ -108,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)); 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_blank_state.vue b/app/assets/javascripts/boards/components/board_blank_state.vue index d8b0b60c183..9f26337d153 100644 --- a/app/assets/javascripts/boards/components/board_blank_state.vue +++ b/app/assets/javascripts/boards/components/board_blank_state.vue @@ -1,6 +1,6 @@ <script> import { __ } from '~/locale'; -/* global ListLabel */ +import ListLabel from '~/boards/models/label'; import Cookies from 'js-cookie'; import boardsStore from '../stores/boards_store'; @@ -30,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(() => { @@ -69,8 +73,7 @@ export default { <span :style="{ backgroundColor: label.color }" class="label-color position-relative d-inline-block rounded" - > - </span> + ></span> {{ label.title }} </li> </ul> diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index 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 787ff110bf8..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,6 +227,7 @@ 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')"> <gl-loading-icon /> 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/modal/footer.vue b/app/assets/javascripts/boards/components/modal/footer.vue index a1d634c8f19..5f100c617a0 100644 --- a/app/assets/javascripts/boards/components/modal/footer.vue +++ b/app/assets/javascripts/boards/components/modal/footer.vue @@ -1,4 +1,5 @@ <script> +import footerEEMixin from 'ee_else_ce/boards/mixins/modal_footer'; import Flash from '../../../flash'; import { __, n__ } from '../../../locale'; import ListsDropdown from './lists_dropdown.vue'; @@ -10,7 +11,7 @@ export default { components: { ListsDropdown, }, - mixins: [modalMixin], + mixins: [modalMixin, footerEEMixin], data() { return { modal: ModalStore.store, @@ -41,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 => { diff --git a/app/assets/javascripts/boards/components/modal/header.vue b/app/assets/javascripts/boards/components/modal/header.vue index 7a696035dc8..8cd4840d3d6 100644 --- a/app/assets/javascripts/boards/components/modal/header.vue +++ b/app/assets/javascripts/boards/components/modal/header.vue @@ -1,4 +1,5 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { __ } from '~/locale'; import ModalFilters from './filters'; import ModalTabs from './tabs.vue'; 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 a1cf1866faf..e8d25e84be1 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -68,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, }); }, }; 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/index.js b/app/assets/javascripts/boards/index.js index d6a5372b22d..3bded4a3258 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -1,7 +1,6 @@ import $ from 'jquery'; import Vue from 'vue'; -import mountMultipleBoardsSwitcher from 'ee_else_ce/boards/mount_multiple_boards_switcher'; import Flash from '~/flash'; import { __ } from '~/locale'; import './models/label'; @@ -31,6 +30,14 @@ import { } 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; @@ -129,6 +136,7 @@ export default () => { }); boardsStore.addBlankState(); + setPromotionState(boardsStore); this.loading = false; }) .catch(() => { @@ -143,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 => { @@ -157,6 +167,8 @@ export default () => { } = convertObjectPropsToCamelCase(data); newIssue.setFetchingState('subscriptions', false); + setWeigthFetchingState(newIssue, false); + setEpicFetchingState(newIssue, false); newIssue.updateData({ humanTimeSpent: humanTotalTimeSpent, timeSpent: totalTimeSpent, @@ -169,6 +181,7 @@ export default () => { }) .catch(() => { newIssue.setFetchingState('subscriptions', false); + setWeigthFetchingState(newIssue, false); Flash(__('An error occurred while fetching sidebar data')); }); } @@ -203,6 +216,7 @@ export default () => { el: document.getElementById('js-add-list'), data: { filters: boardsStore.state.filters, + ...getMilestoneTitle($boardApp), }, mounted() { initNewListDropdown(); @@ -222,6 +236,7 @@ export default () => { return { modal: ModalStore.store, store: boardsStore.state, + ...getBoardsModalData($boardApp), canAdminList: this.$options.el.hasAttribute('data-can-admin-list'), }; }, @@ -285,6 +300,6 @@ export default () => { }); } - toggleFocusMode(ModalStore, boardsStore); + 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/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js index bdb14a7f2f2..8d22f009784 100644 --- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js +++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js @@ -1,2 +1,35 @@ -// this will be moved from EE to CE as part of https://gitlab.com/gitlab-org/gitlab-ce/issues/53811 -export default () => {}; +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 580d04a3649..56a6cab6c73 100644 --- a/app/assets/javascripts/boards/services/board_service.js +++ b/app/assets/javascripts/boards/services/board_service.js @@ -1,4 +1,9 @@ /* 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 + */ import boardsStore from '~/boards/stores/boards_store'; @@ -62,6 +67,22 @@ export default class BoardService { static toggleIssueSubscription(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 }); + } } window.BoardService = BoardService; diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index b9cd4a143ef..f57c684691c 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -340,6 +340,44 @@ const boardsStore = { 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/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 cd2121db3b2..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'; 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 920439ebb23..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,18 +2,23 @@ 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.'), + [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.', ), 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/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/project_form_group.vue b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue index 99d77a75c23..197a0706062 100644 --- a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue +++ b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue @@ -105,7 +105,7 @@ export default { </script> <template> - <div class="form-group"> + <div class="confidential-merge-request-fork-group form-group"> <label>{{ __('Project') }}</label> <div> <dropdown @@ -126,6 +126,14 @@ export default { {{ __('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> 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 052168bb21c..dce9c1a5410 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -182,7 +182,7 @@ export default class CreateMergeRequestDropdown { } enable() { - if (!canCreateConfidentialMergeRequest()) return; + if (isConfidentialIssue() && !canCreateConfidentialMergeRequest()) return; this.createMergeRequestButton.classList.remove('disabled'); this.createMergeRequestButton.removeAttribute('disabled'); 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/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 index 2aa5e9b3339..531ebaddacd 100644 --- a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue +++ b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue @@ -44,7 +44,6 @@ export default { class="d-none d-sm-block" /> <reply-placeholder - class="qa-discussion-reply" :button-text="__('Start a new discussion...')" @onClick="$emit('showNewDiscussionForm')" /> 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 4b226e30699..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; @@ -263,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 af5550aec3b..7ede7a4f430 100644 --- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue +++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue @@ -1,6 +1,7 @@ <script> +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'; @@ -42,7 +43,7 @@ export default { return ''; } - return pluralize(`${this.moreCount} more comment`, this.moreCount); + return n__('%d more comment', '%d more comments', this.moreCount); }, }, methods: { 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 351110f0a87..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: { @@ -115,89 +112,36 @@ export default { 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" - :discussions-expanded="line.discussionsExpanded" - @toggleLineDiscussions=" - toggleLineDiscussions({ lineCode, fileHash, expanded: !line.discussionsExpanded }) - " - /> - </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_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 b2bc3d9914f..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" 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 c477e68c33c..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" diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 32e0d8f42ee..6695d9fe96c 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -109,7 +109,7 @@ export const toggleLineDiscussions = ({ commit }, 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) { @@ -183,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; @@ -195,6 +195,8 @@ export const loadMoreLines = ({ commit }, options) => { contextLines, params, fileHash, + isExpandDown, + nextLineNumbers, }); }); }; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index a66f205bbbd..a6915a46c00 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -71,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, @@ -90,6 +102,7 @@ export default { contextLines: lines, bottom, lineNumbers, + isExpandDown, }); }, diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 1c3ed84001c..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); } } diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 813045cb5e4..95e1e8af9b3 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -1,4 +1,5 @@ <script> +/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { __, sprintf } from '~/locale'; import Timeago from 'timeago.js'; import _ from 'underscore'; @@ -286,9 +287,9 @@ export default { * @returns {Boolean|Undefined} */ isLastDeployment() { - // TODO: when the vue i18n rules are merged need to disable @gitlab/i18n/no-non-i18n-strings // 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?']; }, 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 ef1d1e49320..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"> 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/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/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/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 c8fbc3cb9f1..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}`; 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/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 03756a634d5..802b7f1fa6f 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -4,7 +4,12 @@ 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'; @@ -49,10 +54,10 @@ export default { return this.shouldHideEditor && this.file.mrChange && this.viewer === viewerTypes.mr; }, isEditorViewMode() { - return this.file.viewMode === 'editor'; + return this.file.viewMode === FILE_VIEW_MODE_EDITOR; }, isPreviewViewMode() { - return this.file.viewMode === 'preview'; + return this.file.viewMode === FILE_VIEW_MODE_PREVIEW; }, editTabCSS() { return { @@ -85,7 +90,7 @@ export default { if (this.currentActivityView !== activityBarViews.edit) { this.setFileViewMode({ file: this.file, - viewMode: 'editor', + viewMode: FILE_VIEW_MODE_EDITOR, }); } } @@ -94,7 +99,7 @@ export default { if (this.currentActivityView !== activityBarViews.edit) { this.setFileViewMode({ file: this.file, - viewMode: 'editor', + viewMode: FILE_VIEW_MODE_EDITOR, }); } }, @@ -244,6 +249,8 @@ export default { }, }, viewerTypes, + FILE_VIEW_MODE_EDITOR, + FILE_VIEW_MODE_PREVIEW, }; </script> @@ -255,7 +262,7 @@ 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> @@ -265,7 +272,7 @@ export default { <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 > </li> 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/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/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 ac34491c1ad..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) => { 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/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index a52f1e235ed..1442ea7dbfa 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -43,10 +43,14 @@ export default { [stateEntry, stagedFile, openFile, changedFile].forEach(f => { if (f) { - Object.assign(f, convertObjectPropsToCamelCase(data, { dropKeys: ['raw', 'baseRaw'] }), { - raw: (stateEntry && stateEntry.raw) || null, - baseRaw: null, - }); + Object.assign( + f, + convertObjectPropsToCamelCase(data, { dropKeys: ['path', 'name', 'raw', 'baseRaw'] }), + { + raw: (stateEntry && stateEntry.raw) || null, + baseRaw: null, + }, + ); } }); }, diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 01f78a29cf6..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: '', @@ -43,7 +43,7 @@ export const dataStructure = () => ({ editorColumn: 1, fileLanguage: '', eol: '', - viewMode: 'editor', + viewMode: FILE_VIEW_MODE_EDITOR, previewMode: null, size: 0, parentPath: null, @@ -129,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, @@ -155,11 +155,11 @@ export const createCommitPayload = ({ 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_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 bea43430edc..f50a6e3b19d 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -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 5e90893b684..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,6 +732,66 @@ export const NavigationType = { }; /** + * 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. + * + * 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 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 * it's title. * 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 d38f59b5861..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} 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 9e97f345717..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'; @@ -39,6 +43,17 @@ import 'ee_else_ce/main_ee'; 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; @@ -107,6 +122,7 @@ function deferredInitialisation() { .then(() => { $('select.select2').select2({ width: 'resolve', + minimumResultsForSearch: 10, dropdownAutoWidth: true, }); diff --git a/app/assets/javascripts/manual_ordering.js b/app/assets/javascripts/manual_ordering.js index 012d1e70410..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) || !(gon.current_user_id > 0)) { + 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 ba79a697df2..d330ceb836c 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -1,33 +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 { 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: { @@ -128,6 +141,16 @@ export default { required: false, default: false, }, + alertsEndpoint: { + type: String, + required: false, + default: null, + }, + prometheusAlertsAvailable: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -136,6 +159,7 @@ export default { selectedTimeWindow: '', selectedTimeWindowKey: '', formIsValid: null, + timeWindows: {}, }; }, computed: { @@ -152,12 +176,13 @@ 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; @@ -173,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) { @@ -195,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'), { @@ -220,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); @@ -228,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(); }, @@ -246,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: { @@ -258,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="addingMetricsAvailable"> - <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="alertWidgetAvailable && 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 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/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 721942f9d3b..938ee2f0a9a 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -69,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/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index 164e79c6294..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'; diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue index f4570c1292c..edab750b572 100644 --- a/app/assets/javascripts/notes/components/discussion_actions.vue +++ b/app/assets/javascripts/notes/components/discussion_actions.vue @@ -39,30 +39,27 @@ export default { </script> <template> - <div class="discussion-with-resolve-btn"> + <div class="discussion-with-resolve-btn clearfix"> <reply-placeholder + data-qa-selector="discussion_reply_tab" :button-text="s__('MergeRequests|Reply...')" - class="qa-discussion-reply" @onClick="$emit('showReplyForm')" /> - <resolve-discussion-button - v-if="discussion.resolvable" - :is-resolving="isResolving" - :button-title="resolveButtonTitle" - @onClick="$emit('resolve')" - /> - <div v-if="discussion.resolvable" class="btn-group discussion-actions ml-sm-2" role="group"> - <resolve-with-issue-button v-if="resolveWithIssuePath" :url="resolveWithIssuePath" /> - <jump-to-next-discussion-button - v-if="shouldShowJumpToNextDiscussion" - @onClick="$emit('jumpToNextDiscussion')" - /> + + <div class="btn-group discussion-actions" role="group"> + <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_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/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_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 3823861c0b9..222badf70d1 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -293,12 +293,16 @@ export default { <input v-model="isUnresolving" type="checkbox" - class="qa-unresolve-review-discussion" + data-qa-selector="unresolve_review_discussion_checkbox" /> {{ __('Unresolve thread') }} </template> <template v-else> - <input v-model="isResolving" type="checkbox" class="qa-resolve-review-discussion" /> + <input + v-model="isResolving" + type="checkbox" + data-qa-selector="resolve_review_discussion_checkbox" + /> {{ __('Resolve thread') }} </template> </label> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 2f201839d45..9019f0542b6 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.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,7 +123,13 @@ export default { }, methods: { - ...mapActions(['deleteNote', 'updateNote', 'toggleResolveNote', 'scrollToNoteIfNeeded']), + ...mapActions([ + 'deleteNote', + 'removeNote', + 'updateNote', + 'toggleResolveNote', + 'scrollToNoteIfNeeded', + ]), editHandler() { this.isEditing = true; this.$emit('handleEdit'); @@ -185,15 +192,21 @@ 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) { diff --git a/app/assets/javascripts/notes/services/notes_service.js b/app/assets/javascripts/notes/services/notes_service.js index bc0f5c19b9d..3d239d8cb6b 100644 --- a/app/assets/javascripts/notes/services/notes_service.js +++ b/app/assets/javascripts/notes/services/notes_service.js @@ -5,13 +5,13 @@ 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 }); }, diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 2eefef8bd6e..411bd585672 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -46,9 +46,9 @@ 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); @@ -61,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('updateResolvableDiscussionsCounts'); + 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 }) => @@ -357,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 }) => { @@ -411,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); 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/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/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/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/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/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index b4d24f3aa36..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,4 +1,5 @@ <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'; @@ -27,6 +28,11 @@ export default { type: Object, required: true, }, + canDisableEmails: { + type: Boolean, + required: false, + default: false, + }, canChangeVisibilityLevel: { type: Boolean, required: false, @@ -103,6 +109,7 @@ export default { lfsEnabled: true, requestAccessEnabled: true, highlightChangesClass: false, + emailsDisabled: false, }; return { ...defaults, ...this.currentSettings }; @@ -340,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/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 015c1527500..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,5 +1,4 @@ <script> -import $ from 'jquery'; import { glEmojiTag } from '~/emoji'; import detailedMetric from './detailed_metric.vue'; @@ -28,23 +27,27 @@ 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: 'Redis calls', - details: 'details', + header: s__('PerformanceBar|Redis calls'), keys: ['cmd'], }, ], @@ -66,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; }, @@ -76,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) { @@ -112,27 +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> - <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/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/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue index 3f021a26ec5..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') }} @@ -102,7 +102,11 @@ export default { <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/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js index a6243366375..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() { 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/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 03281aa1317..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 @@ -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() { 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 7752723baac..346dc470a59 100644 --- a/app/assets/javascripts/registry/components/app.vue +++ b/app/assets/javascripts/registry/components/app.vue @@ -1,17 +1,18 @@ <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 SvgMessage from './svg_message.vue'; import { s__, sprintf } from '../../locale'; export default { name: 'RegistryListApp', components: { + clipboardButton, CollapsibleContainer, + GlEmptyState, GlLoadingIcon, - SvgMessage, }, props: { endpoint: { @@ -46,10 +47,10 @@ export default { dockerConnectionErrorText() { return sprintf( s__(`ContainerRegistry|We are having trouble connecting to Docker, which could be due to an - issue with your project name or path. For more information, please review the - %{docLinkStart}Container Registry documentation%{docLinkEnd}.`), + issue with your project name or path. + %{docLinkStart}More Information%{docLinkEnd}`), { - docLinkStart: `<a href="${this.helpPagePath}#docker-connection-error">`, + docLinkStart: `<a href="${this.helpPagePath}#docker-connection-error" target="_blank">`, docLinkEnd: '</a>', }, false, @@ -58,10 +59,10 @@ export default { introText() { return sprintf( s__(`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every - project can have its own space to store its Docker images. Learn more about the - %{docLinkStart}Container Registry%{docLinkEnd}.`), + project can have its own space to store its Docker images. + %{docLinkStart}More Information%{docLinkEnd}`), { - docLinkStart: `<a href="${this.helpPagePath}">`, + docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`, docLinkEnd: '</a>', }, false, @@ -70,20 +71,30 @@ export default { noContainerImagesText() { return sprintf( s__(`ContainerRegistry|With the Container Registry, every project can have its own space to - store its Docker images. Learn more about the %{docLinkStart}Container Registry%{docLinkEnd}.`), + store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`), { - docLinkStart: `<a href="${this.helpPagePath}">`, + 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']), @@ -92,44 +103,63 @@ export default { </script> <template> <div> - <svg-message v-if="characterError" id="invalid-characters" :svg-path="containersErrorImage"> - <h4> - {{ s__('ContainerRegistry|Docker connection error') }} - </h4> - <p v-html="dockerConnectionErrorText"></p> - </svg-message> + <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 && !characterError && repos.length"> + <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> - <svg-message - v-else-if="!isLoading && !characterError && !repos.length" - id="no-container-images" + <gl-empty-state + v-else + :title="s__('ContainerRegistry|There are no container images stored for this project')" :svg-path="noContainersImage" + class="container-message" > - <h4> - {{ s__('ContainerRegistry|There are no container images stored for this project') }} - </h4> - <p v-html="noContainerImagesText"></p> + <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> - <h5>{{ s__('ContainerRegistry|Quick Start') }}</h5> - <p> - {{ - s__( - 'ContainerRegistry|You can add an image to this registry with the following commands:', - ) - }} - </p> + <div class="input-group append-bottom-10"> + <input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly /> + <span class="input-group-append"> + <clipboard-button + :text="dockerBuildCommand" + :title="s__('ContainerRegistry|Copy build command to clipboard')" + class="input-group-text" + /> + </span> + </div> - <pre> - docker build -t {{ repositoryUrl }} . - docker push {{ repositoryUrl }} - </pre> - </svg-message> + <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/svg_message.vue b/app/assets/javascripts/registry/components/svg_message.vue deleted file mode 100644 index d0d44bf2d14..00000000000 --- a/app/assets/javascripts/registry/components/svg_message.vue +++ /dev/null @@ -1,24 +0,0 @@ -<script> -export default { - name: 'RegistrySvgMessage', - props: { - id: { - type: String, - required: true, - }, - svgPath: { - type: String, - required: true, - }, - }, -}; -</script> - -<template> - <div :id="id" class="empty-state container-message mw-70p"> - <div class="svg-content"> - <img :src="svgPath" class="flex-align-self-center" /> - </div> - <slot></slot> - </div> -</template> 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/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 0031ba04d78..7580c2d0ad0 100644 --- a/app/assets/javascripts/releases/components/release_block.vue +++ b/app/assets/javascripts/releases/components/release_block.vue @@ -1,4 +1,5 @@ <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'; 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 0d9e992e596..610c7e8d99e 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -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 3e060e9ecb6..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,9 +115,10 @@ export default { <component :is="linkComponent" :to="routerLinkTo" :href="url" class="str-truncated"> {{ fullPath }} </component> + <!-- 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/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 fa6b6bfaef1..63b93a80ead 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue @@ -1,5 +1,6 @@ <script> import { n__ } from '~/locale'; +import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar'; export default { name: 'AssigneeTitle', @@ -29,13 +30,23 @@ export default { return n__('Assignee', `%d Assignees`, assignees); }, }, + methods: { + trackEdit() { + trackEvent('click_edit_button', 'assignee'); + }, + }, }; </script> <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="#"> + <a + v-if="editable" + class="js-sidebar-dropdown-toggle edit-link float-right" + href="#" + @click.prevent="trackEdit" + > {{ __('Edit') }} </a> <a diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue index 805c21d0965..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 { __, sprintf } 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,171 +25,34 @@ 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() { - const { numberOfHiddenAssignees } = this; - return sprintf(__('+ %{numberOfHiddenAssignees} more'), { numberOfHiddenAssignees }); - }, - 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 sprintf(__('%{canMergeCount}/%{assigneesCount} can merge'), { - canMergeCount, - assigneesCount, - }); + 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 sprintf(__("%{userName}'s avatar"), { userName: user.name }); - }, - 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') }} @@ -198,51 +64,13 @@ export default { </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 be1e4811856..c6cc04a139f 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -29,7 +29,7 @@ export default { }, issuableType: { type: String, - require: true, + required: false, default: 'issue', }, }, 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/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/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 5c7859828d8..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, 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_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 c7b064b8506..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 @@ -50,6 +50,7 @@ export default { startTag: '<span class="label-branch">', endTag: '</span>', }, + false, ); }, }, 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 7312b31c01c..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,7 +18,9 @@ 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"> 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 d4514767912..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 @@ -94,9 +94,6 @@ export default { return __('Merge'); }, - shouldShowMergeOptionsDropdown() { - return this.isAutoMergeAvailable && !this.mr.onlyAllowMergeIfPipelineSucceeds; - }, isRemoveSourceBranchButtonDisabled() { return this.isMergeButtonDisabled; }, @@ -246,7 +243,7 @@ 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" @@ -256,7 +253,7 @@ export default { <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/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 8d415c1bbea..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(); 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 15cb0bd9792..beb2ac09992 100644 --- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue @@ -26,11 +26,6 @@ export default { required: false, default: false, }, - forceModifiedIcon: { - type: Boolean, - required: false, - default: false, - }, size: { type: Number, required: false, @@ -44,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() { @@ -86,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 ae9b013d980..f7c508c4e23 100644 --- a/app/assets/javascripts/vue_shared/components/commit.vue +++ b/app/assets/javascripts/vue_shared/components/commit.vue @@ -152,37 +152,35 @@ 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> + <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> 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 fc6a45b957e..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, diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index fe5289ff371..f49e69c473b 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -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/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/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/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index 8ce5b615795..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 { 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/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/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(); + } + }, +}; |