diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2019-12-20 09:07:57 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2019-12-20 09:07:57 +0000 |
commit | 7881eb30eaa8b01dbcfe87faa09927c75c7d6e45 (patch) | |
tree | 298bc8d2c62b2f2c29cb8ecbcf3de3eaaa6466d9 /app/assets | |
parent | 64b66e0cb6d1bfd27abf24e06653f00bddb60597 (diff) | |
download | gitlab-ce-7881eb30eaa8b01dbcfe87faa09927c75c7d6e45.tar.gz |
Add latest changes from gitlab-org/gitlab@12-6-stable-ee
Diffstat (limited to 'app/assets')
360 files changed, 5600 insertions, 3254 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index aee9990bc0b..071ae8ca8cf 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -5,6 +5,8 @@ import { joinPaths } from './lib/utils/url_utility'; import flash from '~/flash'; import { __ } from '~/locale'; +const DEFAULT_PER_PAGE = 20; + const Api = { groupsPath: '/api/:version/groups.json', groupPath: '/api/:version/groups/:id', @@ -41,7 +43,7 @@ const Api = { releasesPath: '/api/:version/projects/:id/releases', releasePath: '/api/:version/projects/:id/releases/:tag_name', mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines', - adminStatisticsPath: 'api/:version/application/statistics', + adminStatisticsPath: '/api/:version/application/statistics', group(groupId, callback) { const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); @@ -66,7 +68,7 @@ const Api = { params: Object.assign( { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, }, options, ), @@ -90,7 +92,7 @@ const Api = { .get(url, { params: { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, }, }) .then(({ data }) => callback(data)); @@ -101,7 +103,7 @@ const Api = { const url = Api.buildUrl(Api.projectsPath); const defaults = { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, simple: true, }; @@ -126,7 +128,7 @@ const Api = { .get(url, { params: { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, ...options, }, }) @@ -235,7 +237,7 @@ const Api = { const url = Api.buildUrl(Api.groupProjectsPath).replace(':id', groupId); const defaults = { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, }; return axios .get(url, { @@ -325,7 +327,7 @@ const Api = { params: Object.assign( { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, }, options, ), @@ -355,7 +357,7 @@ const Api = { const url = Api.buildUrl(Api.userProjectsPath).replace(':id', userId); const defaults = { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, }; return axios .get(url, { @@ -371,7 +373,7 @@ const Api = { return axios.get(url, { params: { search: query, - per_page: 20, + per_page: DEFAULT_PER_PAGE, ...options, }, }); @@ -403,10 +405,15 @@ const Api = { return axios.post(url); }, - releases(id) { + releases(id, options = {}) { const url = Api.buildUrl(this.releasesPath).replace(':id', encodeURIComponent(id)); - return axios.get(url); + return axios.get(url, { + params: { + per_page: DEFAULT_PER_PAGE, + ...options, + }, + }); }, release(projectPath, tagName) { diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js index 7652b67ae1e..07d79ea1c70 100644 --- a/app/assets/javascripts/autosave.js +++ b/app/assets/javascripts/autosave.js @@ -1,9 +1,9 @@ -/* eslint-disable no-param-reassign, no-void, consistent-return */ +/* eslint-disable no-param-reassign, consistent-return */ import AccessorUtilities from './lib/utils/accessor'; export default class Autosave { - constructor(field, key) { + constructor(field, key, fallbackKey) { this.field = field; this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); @@ -11,6 +11,7 @@ export default class Autosave { key = key.join('/'); } this.key = `autosave/${key}`; + this.fallbackKey = fallbackKey; this.field.data('autosave', this); this.restore(); this.field.on('input', () => this.save()); @@ -21,9 +22,12 @@ export default class Autosave { if (!this.field.length) return; const text = window.localStorage.getItem(this.key); + const fallbackText = window.localStorage.getItem(this.fallbackKey); - if ((text != null ? text.length : void 0) > 0) { + if (text) { this.field.val(text); + } else if (fallbackText) { + this.field.val(fallbackText); } this.field.trigger('input'); @@ -41,7 +45,10 @@ export default class Autosave { const text = this.field.val(); - if (this.isLocalStorageAvailable && (text != null ? text.length : void 0) > 0) { + if (this.isLocalStorageAvailable && text) { + if (this.fallbackKey) { + window.localStorage.setItem(this.fallbackKey, text); + } return window.localStorage.setItem(this.key, text); } @@ -51,6 +58,7 @@ export default class Autosave { reset() { if (!this.isLocalStorageAvailable) return; + window.localStorage.removeItem(this.fallbackKey); return window.localStorage.removeItem(this.key); } diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue index eb720f5380b..00c0334db77 100644 --- a/app/assets/javascripts/badges/components/badge.vue +++ b/app/assets/javascripts/badges/components/badge.vue @@ -1,6 +1,6 @@ <script> -import Icon from '~/vue_shared/components/icon.vue'; import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; export default { // name: 'Badge' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25 @@ -14,6 +14,11 @@ export default { GlTooltip: GlTooltipDirective, }, props: { + name: { + type: String, + required: false, + default: '', + }, imageUrl: { type: String, required: true, diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue index df74eb2c2f7..19668d7e232 100644 --- a/app/assets/javascripts/badges/components/badge_form.vue +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -1,10 +1,10 @@ <script> import _ from 'underscore'; import { mapActions, mapState } from 'vuex'; +import { GlLoadingIcon, GlFormInput, GlFormGroup } from '@gitlab/ui'; import createFlash from '~/flash'; import { s__, sprintf } from '~/locale'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; -import { GlLoadingIcon } from '@gitlab/ui'; import createEmptyBadge from '../empty_badge'; import Badge from './badge.vue'; @@ -16,6 +16,8 @@ export default { Badge, LoadingButton, GlLoadingIcon, + GlFormInput, + GlFormGroup, }, props: { isEditing: { @@ -64,6 +66,18 @@ export default { renderedLinkUrl() { return this.renderedBadge ? this.renderedBadge.renderedLinkUrl : ''; }, + name: { + get() { + return this.badge ? this.badge.name : ''; + }, + set(name) { + const badge = this.badge || createEmptyBadge(); + this.updateBadgeInForm({ + ...badge, + name, + }); + }, + }, imageUrl: { get() { return this.badge ? this.badge.imageUrl : ''; @@ -154,6 +168,10 @@ export default { novalidate @submit.prevent.stop="onSubmit" > + <gl-form-group :label="s__('Badges|Name')" label-for="badge-name"> + <gl-form-input id="badge-name" v-model="name" /> + </gl-form-group> + <div class="form-group"> <label for="badge-link-url" class="label-bold">{{ s__('Badges|Link') }}</label> <p v-html="helpText"></p> diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue index cad5611c8c5..bb363b8d85e 100644 --- a/app/assets/javascripts/badges/components/badge_list_row.vue +++ b/app/assets/javascripts/badges/components/badge_list_row.vue @@ -1,8 +1,8 @@ <script> import { mapActions, mapState } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; import { s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; -import { GlLoadingIcon } from '@gitlab/ui'; import { PROJECT_BADGE } from '../constants'; import Badge from './badge.vue'; @@ -43,13 +43,14 @@ export default { <badge :image-url="badge.renderedImageUrl" :link-url="badge.renderedLinkUrl" - class="table-section section-40" + class="table-section section-30" /> - <span class="table-section section-30 str-truncated">{{ badge.linkUrl }}</span> - <div class="table-section section-15"> + <div class="table-section section-30"> + <label class="label-bold str-truncated mb-0">{{ badge.name }}</label> <span class="badge badge-pill">{{ badgeKindText }}</span> </div> - <div class="table-section section-15 table-button-footer"> + <span class="table-section section-30 str-truncated">{{ badge.linkUrl }}</span> + <div class="table-section section-10 table-button-footer"> <div v-if="canEditBadge" class="table-action-buttons"> <button :disabled="badge.isDeleting" diff --git a/app/assets/javascripts/badges/empty_badge.js b/app/assets/javascripts/badges/empty_badge.js index 49a9b5e1be8..527f233bb33 100644 --- a/app/assets/javascripts/badges/empty_badge.js +++ b/app/assets/javascripts/badges/empty_badge.js @@ -1,4 +1,5 @@ export default () => ({ + name: '', imageUrl: '', isDeleting: false, linkUrl: '', diff --git a/app/assets/javascripts/badges/store/actions.js b/app/assets/javascripts/badges/store/actions.js index 5542278b3e0..806c2423e7e 100644 --- a/app/assets/javascripts/badges/store/actions.js +++ b/app/assets/javascripts/badges/store/actions.js @@ -1,13 +1,9 @@ import axios from '~/lib/utils/axios_utils'; import types from './mutation_types'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; export const transformBackendBadge = badge => ({ - id: badge.id, - imageUrl: badge.image_url, - kind: badge.kind, - linkUrl: badge.link_url, - renderedImageUrl: badge.rendered_image_url, - renderedLinkUrl: badge.rendered_link_url, + ...convertObjectPropsToCamelCase(badge, true), isDeleting: false, }); @@ -27,6 +23,7 @@ export default { dispatch('requestNewBadge'); return axios .post(endpoint, { + name: newBadge.name, image_url: newBadge.imageUrl, link_url: newBadge.linkUrl, }) @@ -141,6 +138,7 @@ export default { dispatch('requestUpdatedBadge'); return axios .put(endpoint, { + name: badge.name, image_url: badge.imageUrl, link_url: badge.linkUrl, }) diff --git a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js index b7200150925..6bbd2133344 100644 --- a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js +++ b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js @@ -1,6 +1,6 @@ import $ from 'jquery'; -import { parseBoolean } from '~/lib/utils/common_utils'; import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; +import { parseBoolean } from '~/lib/utils/common_utils'; export default function initGFMInput() { $('.js-gfm-input:not(.js-vue-textarea)').each((i, el) => { diff --git a/app/assets/javascripts/behaviors/markdown/nodes/image.js b/app/assets/javascripts/behaviors/markdown/nodes/image.js index c225a5ed876..e839396330e 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/image.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/image.js @@ -1,8 +1,8 @@ /* eslint-disable class-methods-use-this */ import { Image as BaseImage } from 'tiptap-extensions'; -import { placeholderImage } from '~/lazy_loader'; import { defaultMarkdownSerializer } from 'prosemirror-markdown'; +import { placeholderImage } from '~/lazy_loader'; export default class Image extends BaseImage { get schema() { diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js index aedd8004ea5..2df7a84ead0 100644 --- a/app/assets/javascripts/blob/file_template_mediator.js +++ b/app/assets/javascripts/blob/file_template_mediator.js @@ -1,6 +1,6 @@ +import $ from 'jquery'; import Api from '~/api'; -import $ from 'jquery'; import Flash from '../flash'; import FileTemplateTypeSelector from './template_selectors/type_selector'; import BlobCiYamlSelector from './template_selectors/ci_yaml_selector'; diff --git a/app/assets/javascripts/blob/openapi/index.js b/app/assets/javascripts/blob/openapi/index.js new file mode 100644 index 00000000000..a6f28de799f --- /dev/null +++ b/app/assets/javascripts/blob/openapi/index.js @@ -0,0 +1,19 @@ +import { SwaggerUIBundle } from 'swagger-ui-dist'; +import flash from '~/flash'; +import { __ } from '~/locale'; + +export default () => { + const el = document.getElementById('js-openapi-viewer'); + + Promise.all([import(/* webpackChunkName: 'openapi' */ 'swagger-ui-dist/swagger-ui.css')]) + .then(() => { + SwaggerUIBundle({ + url: el.dataset.endpoint, + dom_id: '#js-openapi-viewer', + }); + }) + .catch(error => { + flash(__('Something went wrong while initializing the OpenAPI viewer')); + throw error; + }); +}; diff --git a/app/assets/javascripts/blob/openapi_viewer.js b/app/assets/javascripts/blob/openapi_viewer.js new file mode 100644 index 00000000000..0cacc33571f --- /dev/null +++ b/app/assets/javascripts/blob/openapi_viewer.js @@ -0,0 +1,3 @@ +import renderOpenApi from './openapi'; + +export default renderOpenApi; diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index 07e4dde41d9..742404da46c 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -39,6 +39,9 @@ export default class BlobViewer { case 'notebook': initViewer(import(/* webpackChunkName: 'notebook_viewer' */ '../notebook_viewer')); break; + case 'openapi': + initViewer(import(/* webpackChunkName: 'openapi_viewer' */ '../openapi_viewer')); + break; case 'pdf': initViewer(import(/* webpackChunkName: 'pdf_viewer' */ '../pdf_viewer')); break; diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js index 58759fd1efe..8ebdfede8f7 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -1,6 +1,8 @@ import $ from 'jquery'; import Sortable from 'sortablejs'; import Vue from 'vue'; +import { GlButtonGroup, GlButton, GlTooltip } from '@gitlab/ui'; +import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits'; import { n__, s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import Tooltip from '~/vue_shared/directives/tooltip'; @@ -8,8 +10,10 @@ import AccessorUtilities from '../../lib/utils/accessor'; import BoardBlankState from './board_blank_state.vue'; import BoardDelete from './board_delete'; import BoardList from './board_list.vue'; +import IssueCount from './issue_count.vue'; import boardsStore from '../stores/boards_store'; import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options'; +import { ListType } from '../constants'; export default Vue.extend({ components: { @@ -17,10 +21,15 @@ export default Vue.extend({ BoardDelete, BoardList, Icon, + GlButtonGroup, + IssueCount, + GlButton, + GlTooltip, }, directives: { Tooltip, }, + mixins: [isWipLimitsOn], props: { list: { type: Object, @@ -53,6 +62,11 @@ export default Vue.extend({ isLoggedIn() { return Boolean(gon.current_user_id); }, + showListHeaderButton() { + return ( + !this.disabled && this.list.type !== ListType.closed && this.list.type !== ListType.blank + ); + }, counterTooltip() { const { issuesSize } = this.list; return `${n__('%d issue', '%d issues', issuesSize)}`; @@ -61,11 +75,19 @@ export default Vue.extend({ return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand'); }, isNewIssueShown() { + return this.list.type === ListType.backlog || this.showListHeaderButton; + }, + isSettingsShown() { return ( - this.list.type === 'backlog' || - (!this.disabled && this.list.type !== 'closed' && this.list.type !== 'blank') + this.list.type !== ListType.backlog && + this.showListHeaderButton && + this.list.isExpanded && + this.isWipLimitsOn ); }, + showBoardListAndBoardInfo() { + return this.list.type !== ListType.blank && this.list.type !== ListType.promotion; + }, uniqueKey() { // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings return `boards.${this.boardId}.${this.list.type}.${this.list.id}`; diff --git a/app/assets/javascripts/boards/components/board_blank_state.vue b/app/assets/javascripts/boards/components/board_blank_state.vue index 9a1da810ad0..afdf0290e8e 100644 --- a/app/assets/javascripts/boards/components/board_blank_state.vue +++ b/app/assets/javascripts/boards/components/board_blank_state.vue @@ -1,7 +1,7 @@ <script> +import Cookies from 'js-cookie'; import { __ } from '~/locale'; import ListLabel from '~/boards/models/label'; -import Cookies from 'js-cookie'; import boardsStore from '../stores/boards_store'; export default { diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index b8439bc8741..1e54d4d6b7d 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -71,6 +71,9 @@ export default { total: this.list.issuesSize, }); }, + issuesSizeExceedsMax() { + return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount; + }, }, watch: { filters: { @@ -435,7 +438,7 @@ export default { ref="list" :data-board="list.id" :data-board-type="list.type" - :class="{ 'is-smaller': showIssueForm }" + :class="{ 'is-smaller': showIssueForm, 'bg-danger-100': issuesSizeExceedsMax }" class="board-list w-100 h-100 list-unstyled mb-0 p-1 js-board-list" > <board-card diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 32491dfbcb6..5d7be0c705a 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -315,8 +315,7 @@ export default { <gl-dropdown-item v-if="showDelete" - class="text-danger" - data-qa-selector="delete_board_button" + class="text-danger js-delete-board" @click.prevent="showPage('delete')" > {{ s__('IssueBoards|Delete board') }} diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index d37e49bab46..7f7510545c6 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -2,10 +2,10 @@ import _ from 'underscore'; import { mapState } from 'vuex'; import { GlTooltipDirective } from '@gitlab/ui'; +import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner'; import { sprintf, __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; -import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner'; import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import IssueDueDate from './issue_due_date.vue'; import IssueTimeEstimate from './issue_time_estimate.vue'; diff --git a/app/assets/javascripts/boards/components/issue_count.vue b/app/assets/javascripts/boards/components/issue_count.vue new file mode 100644 index 00000000000..c50a3c1c0d3 --- /dev/null +++ b/app/assets/javascripts/boards/components/issue_count.vue @@ -0,0 +1,36 @@ +<script> +export default { + name: 'IssueCount', + props: { + maxIssueCount: { + type: Number, + required: false, + default: 0, + }, + issuesSize: { + type: Number, + required: false, + default: 0, + }, + }, + computed: { + isMaxLimitSet() { + return this.maxIssueCount !== 0; + }, + issuesExceedMax() { + return this.isMaxLimitSet && this.issuesSize > this.maxIssueCount; + }, + }, +}; +</script> + +<template> + <div class="issue-count"> + <span class="js-issue-size" :class="{ 'text-danger': issuesExceedMax }"> + {{ issuesSize }} + </span> + <span v-if="isMaxLimitSet" class="js-max-issue-size"> + {{ maxIssueCount }} + </span> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue index 3bc7f13a9e6..a32ebdab5e1 100644 --- a/app/assets/javascripts/boards/components/issue_due_date.vue +++ b/app/assets/javascripts/boards/components/issue_due_date.vue @@ -35,10 +35,10 @@ export default { title() { const timeago = getTimeago(); const { timeDifference, standardDateFormat } = this; - const formatedDate = standardDateFormat; + const formattedDate = standardDateFormat; if (timeDifference >= -1 && timeDifference < 7) { - return `${timeago.format(this.issueDueDate)} (${formatedDate})`; + return `${timeago.format(this.issueDueDate)} (${formattedDate})`; } return timeago.format(this.issueDueDate); diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue index 618c2ada1f8..20344b66140 100644 --- a/app/assets/javascripts/boards/components/modal/index.vue +++ b/app/assets/javascripts/boards/components/modal/index.vue @@ -1,5 +1,6 @@ <script> /* global ListIssue */ +import { GlLoadingIcon } from '@gitlab/ui'; import { urlParamsToObject } from '~/lib/utils/common_utils'; import boardsStore from '~/boards/stores/boards_store'; import ModalHeader from './header.vue'; @@ -7,7 +8,6 @@ import ModalList from './list.vue'; import ModalFooter from './footer.vue'; import EmptyState from './empty_state.vue'; import ModalStore from '../../stores/modal_store'; -import { GlLoadingIcon } from '@gitlab/ui'; export default { components: { diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index e5ebb887ce0..4a50b1e2efc 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -1,9 +1,9 @@ <script> -import { __ } from '~/locale'; import $ from 'jquery'; import _ from 'underscore'; -import Icon from '~/vue_shared/components/icon.vue'; import { GlLoadingIcon } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import { __ } from '~/locale'; import eventHub from '../eventhub'; import Api from '../../api'; import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 3c66c7a0660..dcecfe5e1bb 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -4,6 +4,8 @@ export const ListType = { backlog: 'backlog', closed: 'closed', label: 'label', + promotion: 'promotion', + blank: 'blank', }; export default { diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index e76e2341dfd..f1b481fc386 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -1,6 +1,22 @@ import $ from 'jquery'; import Vue from 'vue'; +import 'ee_else_ce/boards/models/issue'; +import 'ee_else_ce/boards/models/list'; +import Board from 'ee_else_ce/boards/components/board'; +import BoardSidebar from 'ee_else_ce/boards/components/board_sidebar'; +import initNewListDropdown from 'ee_else_ce/boards/components/new_list_dropdown'; +import boardConfigToggle from 'ee_else_ce/boards/config_toggle'; +import toggleFocusMode from 'ee_else_ce/boards/toggle_focus'; +import toggleLabels from 'ee_else_ce/boards/toggle_labels'; +import { + setPromotionState, + setWeigthFetchingState, + setEpicFetchingState, + getMilestoneTitle, + getBoardsModalData, +} from 'ee_else_ce/boards/ee_functions'; + import Flash from '~/flash'; import { __ } from '~/locale'; import './models/label'; @@ -9,35 +25,19 @@ import './models/assignee'; import FilteredSearchBoards from '~/boards/filtered_search_boards'; import eventHub from '~/boards/eventhub'; import sidebarEventHub from '~/sidebar/event_hub'; -import 'ee_else_ce/boards/models/issue'; -import 'ee_else_ce/boards/models/list'; import '~/boards/models/milestone'; import '~/boards/models/project'; import store from '~/boards/stores'; import boardsStore from '~/boards/stores/boards_store'; import ModalStore from '~/boards/stores/modal_store'; -import BoardService from 'ee_else_ce/boards/services/board_service'; import modalMixin from '~/boards/mixins/modal_mixins'; import '~/boards/filters/due_date_filters'; -import Board from 'ee_else_ce/boards/components/board'; -import BoardSidebar from 'ee_else_ce/boards/components/board_sidebar'; -import initNewListDropdown from 'ee_else_ce/boards/components/new_list_dropdown'; import BoardAddIssuesModal from '~/boards/components/modal/index.vue'; import { NavigationType, convertObjectPropsToCamelCase, parseBoolean, } from '~/lib/utils/common_utils'; -import boardConfigToggle from 'ee_else_ce/boards/config_toggle'; -import toggleFocusMode from 'ee_else_ce/boards/toggle_focus'; -import toggleLabels from 'ee_else_ce/boards/toggle_labels'; -import { - setPromotionState, - setWeigthFetchingState, - setEpicFetchingState, - getMilestoneTitle, - getBoardsModalData, -} from 'ee_else_ce/boards/ee_functions'; import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher'; let issueBoardsApp; @@ -68,6 +68,8 @@ export default () => { Board, BoardSidebar, BoardAddIssuesModal, + BoardSettingsSidebar: () => + import('ee_component/boards/components/board_settings_sidebar.vue'), }, store, data: { @@ -97,7 +99,6 @@ export default () => { bulkUpdatePath: this.bulkUpdatePath, boardId: this.boardId, }); - gl.boardService = new BoardService(); boardsStore.rootPath = this.boardsEndpoint; eventHub.$on('updateTokens', this.updateTokens); @@ -116,7 +117,7 @@ export default () => { this.filterManager.setup(); boardsStore.disabled = this.disabled; - gl.boardService + boardsStore .all() .then(res => res.data) .then(lists => { @@ -155,7 +156,8 @@ export default () => { newIssue.setFetchingState('subscriptions', true); setWeigthFetchingState(newIssue, true); setEpicFetchingState(newIssue, true); - BoardService.getIssueInfo(sidebarInfoEndpoint) + boardsStore + .getIssueInfo(sidebarInfoEndpoint) .then(res => res.data) .then(data => { const { @@ -166,6 +168,7 @@ export default () => { humanTotalTimeSpent, weight, epic, + assignees, } = convertObjectPropsToCamelCase(data); newIssue.setFetchingState('subscriptions', false); @@ -179,6 +182,7 @@ export default () => { subscribed, weight, epic, + assignees, }); }) .catch(() => { @@ -211,7 +215,8 @@ export default () => { const { issue } = boardsStore.detail; if (issue.id === id && issue.toggleSubscriptionEndpoint) { issue.setFetchingState('subscriptions', true); - BoardService.toggleIssueSubscription(issue.toggleSubscriptionEndpoint) + boardsStore + .toggleIssueSubscription(issue.toggleSubscriptionEndpoint) .then(() => { issue.setFetchingState('subscriptions', false); issue.updateData({ diff --git a/app/assets/javascripts/boards/mixins/is_wip_limits.js b/app/assets/javascripts/boards/mixins/is_wip_limits.js new file mode 100644 index 00000000000..f172179d3c7 --- /dev/null +++ b/app/assets/javascripts/boards/mixins/is_wip_limits.js @@ -0,0 +1,7 @@ +export default { + computed: { + isWipLimitsOn() { + return false; + }, + }, +}; diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index bb8c8e68297..b232fea0882 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -1,9 +1,9 @@ /* eslint-disable no-underscore-dangle, class-methods-use-this, consistent-return, no-shadow */ +import ListIssue from 'ee_else_ce/boards/models/issue'; import { __ } from '~/locale'; import ListLabel from './label'; import ListAssignee from './assignee'; -import ListIssue from 'ee_else_ce/boards/models/issue'; import { urlParamsToObject } from '~/lib/utils/common_utils'; import flash from '~/flash'; import boardsStore from '../stores/boards_store'; @@ -52,6 +52,9 @@ class List { this.loadingMore = false; this.issues = obj.issues || []; this.issuesSize = obj.issuesSize ? obj.issuesSize : 0; + this.maxIssueCount = Object.hasOwnProperty.call(obj, 'max_issue_count') + ? obj.max_issue_count + : 0; this.defaultAvatar = defaultAvatar; if (obj.label) { @@ -90,7 +93,7 @@ class List { entityType = 'milestone_id'; } - return gl.boardService + return boardsStore .createList(entity.id, entityType) .then(res => res.data) .then(data => { @@ -108,14 +111,14 @@ class List { boardsStore.state.lists.splice(index, 1); boardsStore.updateNewListDropdown(this.id); - gl.boardService.destroyList(this.id).catch(() => { + boardsStore.destroyList(this.id).catch(() => { // TODO: handle request error }); } update() { const collapsed = !this.isExpanded; - return gl.boardService.updateList(this.id, this.position, collapsed).catch(() => { + return boardsStore.updateList(this.id, this.position, collapsed).catch(() => { // TODO: handle request error }); } @@ -144,7 +147,7 @@ class List { this.loading = true; } - return gl.boardService + return boardsStore .getIssuesForList(this.id, data) .then(res => res.data) .then(data => { @@ -165,7 +168,7 @@ class List { this.addIssue(issue, null, 0); this.issuesSize += 1; - return gl.boardService + return boardsStore .newIssue(this.id, issue) .then(res => res.data) .then(data => this.onNewIssueResponse(issue, data)); @@ -273,7 +276,7 @@ class List { this.issues.splice(oldIndex, 1); this.issues.splice(newIndex, 0, issue); - gl.boardService.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId).catch(() => { + boardsStore.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId).catch(() => { // TODO: handle request error }); } @@ -284,7 +287,7 @@ class List { }); this.issues.splice(newIndex, 0, ...issues); - gl.boardService + boardsStore .moveMultipleIssues({ ids: issues.map(issue => issue.id), fromListId: null, @@ -296,15 +299,13 @@ class List { } updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) { - gl.boardService - .moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId) - .catch(() => { - // TODO: handle request error - }); + boardsStore.moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId).catch(() => { + // TODO: handle request error + }); } updateMultipleIssues(issues, listFrom, moveBeforeId, moveAfterId) { - gl.boardService + boardsStore .moveMultipleIssues({ ids: issues.map(issue => issue.id), fromListId: listFrom.id, @@ -356,7 +357,7 @@ class List { if (this.issuesSize > 1) { const moveBeforeId = this.issues[1].id; - gl.boardService.moveIssue(issue.id, null, null, null, moveBeforeId); + boardsStore.moveIssue(issue.id, null, null, null, moveBeforeId); } } } diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js deleted file mode 100644 index 03369febb4a..00000000000 --- a/app/assets/javascripts/boards/services/board_service.js +++ /dev/null @@ -1,98 +0,0 @@ -/* 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-foss/issues/61621 - */ - -import boardsStore from '~/boards/stores/boards_store'; - -export default class BoardService { - generateBoardsPath(id) { - return boardsStore.generateBoardsPath(id); - } - - generateIssuesPath(id) { - return boardsStore.generateIssuesPath(id); - } - - static generateIssuePath(boardId, id) { - return boardsStore.generateIssuePath(boardId, id); - } - - all() { - return boardsStore.all(); - } - - generateDefaultLists() { - return boardsStore.generateDefaultLists(); - } - - createList(entityId, entityType) { - return boardsStore.createList(entityId, entityType); - } - - updateList(id, position, collapsed) { - return boardsStore.updateList(id, position, collapsed); - } - - destroyList(id) { - return boardsStore.destroyList(id); - } - - getIssuesForList(id, filter = {}) { - return boardsStore.getIssuesForList(id, filter); - } - - moveIssue(id, fromListId = null, toListId = null, moveBeforeId = null, moveAfterId = null) { - return boardsStore.moveIssue(id, fromListId, toListId, moveBeforeId, moveAfterId); - } - - moveMultipleIssues({ - ids, - fromListId = null, - toListId = null, - moveBeforeId = null, - moveAfterId = null, - }) { - return boardsStore.moveMultipleIssues({ ids, fromListId, toListId, moveBeforeId, moveAfterId }); - } - - newIssue(id, issue) { - return boardsStore.newIssue(id, issue); - } - - getBacklog(data) { - return boardsStore.getBacklog(data); - } - - bulkUpdate(issueIds, extraData = {}) { - return boardsStore.bulkUpdate(issueIds, extraData); - } - - static getIssueInfo(endpoint) { - return boardsStore.getIssueInfo(endpoint); - } - - 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/state.js b/app/assets/javascripts/boards/stores/state.js index 24f44dc5629..731aea996fb 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -1,3 +1,4 @@ export default () => ({ isShowingLabels: true, + activeListId: 0, }); diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 75909dd9d20..d990d2677a8 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -1,7 +1,7 @@ import Visibility from 'visibilityjs'; import Vue from 'vue'; -import AccessorUtilities from '~/lib/utils/accessor'; import { GlToast } from '@gitlab/ui'; +import AccessorUtilities from '~/lib/utils/accessor'; import PersistentUserCallout from '../persistent_user_callout'; import { s__, sprintf } from '../locale'; import Flash from '../flash'; @@ -12,6 +12,7 @@ import { APPLICATION_STATUS, INGRESS, INGRESS_DOMAIN_SUFFIX, CROSSPLANE } from ' import ClustersService from './services/clusters_service'; import ClustersStore from './stores/clusters_store'; import Applications from './components/applications.vue'; +import RemoveClusterConfirmation from './components/remove_cluster_confirmation.vue'; import setupToggleButtons from '../toggle_buttons'; import initProjectSelectDropdown from '~/project_select'; @@ -144,6 +145,8 @@ export default class Clusters { () => this.handlePollError(), ); } + + this.initRemoveClusterActions(); } initApplications(type) { @@ -205,6 +208,25 @@ export default class Clusters { }); } + initRemoveClusterActions() { + const el = document.querySelector('#js-cluster-remove-actions'); + if (el && el.dataset) { + const { clusterName, clusterPath } = el.dataset; + + this.removeClusterAction = new Vue({ + el, + render(createElement) { + return createElement(RemoveClusterConfirmation, { + props: { + clusterName, + clusterPath, + }, + }); + }, + }); + } + } + handleClusterEnvironmentsSuccess(data) { this.store.toggleFetchEnvironments(false); this.store.updateEnvironments(data.data); diff --git a/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue index 966918ae636..6b99bb09504 100644 --- a/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue +++ b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue @@ -1,6 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; +import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; import { s__ } from '../../locale'; export default { @@ -8,7 +7,7 @@ export default { components: { GlDropdown, GlDropdownItem, - Icon, + GlIcon, }, props: { stacks: { @@ -86,8 +85,9 @@ export default { href="https://crossplane.io/docs/master/stacks-guide.html" target="_blank" rel="noopener noreferrer" - >{{ __('Crossplane') }}</a - > + >{{ __('Crossplane') }} + <gl-icon name="external-link" class="vertical-align-middle" /> + </a> </p> </div> </template> diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue index 25347b11b6c..66c8297cb75 100644 --- a/app/assets/javascripts/clusters/components/knative_domain_editor.vue +++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue @@ -1,7 +1,7 @@ <script> +import { GlLoadingIcon } from '@gitlab/ui'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; -import { GlLoadingIcon } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { APPLICATION_STATUS } from '~/clusters/constants'; diff --git a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue new file mode 100644 index 00000000000..c31ba7ef14a --- /dev/null +++ b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue @@ -0,0 +1,168 @@ +<script> +import _ from 'underscore'; +import SplitButton from '~/vue_shared/components/split_button.vue'; +import { GlModal, GlButton, GlFormInput } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; +import csrf from '~/lib/utils/csrf'; + +const splitButtonActionItems = [ + { + title: s__('ClusterIntegration|Remove integration and resources'), + description: s__( + 'ClusterIntegration|Deletes all GitLab resources attached to this cluster during removal', + ), + eventName: 'remove-cluster-and-cleanup', + }, + { + title: s__('ClusterIntegration|Remove integration'), + description: s__( + 'ClusterIntegration|Removes cluster from project but keeps associated resources', + ), + eventName: 'remove-cluster', + }, +]; + +export default { + splitButtonActionItems, + components: { + SplitButton, + GlModal, + GlButton, + GlFormInput, + }, + props: { + clusterPath: { + type: String, + required: true, + }, + clusterName: { + type: String, + required: true, + }, + }, + data() { + return { + enteredClusterName: '', + confirmCleanup: false, + }; + }, + computed: { + csrfToken() { + return csrf.token; + }, + modalTitle() { + return this.confirmCleanup + ? s__('ClusterIntegration|Remove integration and resources?') + : s__('ClusterIntegration|Remove integration?'); + }, + warningMessage() { + return this.confirmCleanup + ? s__( + 'ClusterIntegration|You are about to remove your cluster integration and all GitLab-created resources associated with this cluster.', + ) + : s__('ClusterIntegration|You are about to remove your cluster integration.'); + }, + warningToBeRemoved() { + return s__(`ClusterIntegration| + This will permanently delete the following resources: + <ul> + <li>All installed applications and related resources</li> + <li>The <code>gitlab-managed-apps</code> namespace</li> + <li>Any project namespaces</li> + <li><code>clusterroles</code></li> + <li><code>clusterrolebindings</code></li> + </ul> + `); + }, + confirmationTextLabel() { + return sprintf( + this.confirmCleanup + ? s__( + 'ClusterIntegration|To remove your integration and resources, type %{clusterName} to confirm:', + ) + : s__('ClusterIntegration|To remove your integration, type %{clusterName} to confirm:'), + { + clusterName: `<code>${_.escape(this.clusterName)}</code>`, + }, + false, + ); + }, + canSubmit() { + return this.enteredClusterName === this.clusterName; + }, + }, + methods: { + handleClickRemoveCluster(cleanup = false) { + this.confirmCleanup = cleanup; + this.$refs.modal.show(); + }, + handleCancel() { + this.$refs.modal.hide(); + this.enteredClusterName = ''; + }, + handleSubmit(cleanup = false) { + this.$refs.cleanup.name = cleanup === true ? 'cleanup' : 'no_cleanup'; + this.$refs.form.submit(); + this.enteredClusterName = ''; + }, + }, +}; +</script> + +<template> + <div> + <split-button + :action-items="$options.splitButtonActionItems" + menu-class="dropdown-menu-large" + variant="danger" + @remove-cluster="handleClickRemoveCluster(false)" + @remove-cluster-and-cleanup="handleClickRemoveCluster(true)" + /> + <gl-modal + ref="modal" + size="lg" + modal-id="delete-cluster-modal" + :title="modalTitle" + kind="danger" + > + <template> + <p>{{ warningMessage }}</p> + <div v-if="confirmCleanup" v-html="warningToBeRemoved"></div> + <strong v-html="confirmationTextLabel"></strong> + <form ref="form" :action="clusterPath" method="post" class="append-bottom-20"> + <input ref="method" type="hidden" name="_method" value="delete" /> + <input :value="csrfToken" type="hidden" name="authenticity_token" /> + <input ref="cleanup" type="hidden" name="cleanup" value="true" /> + <gl-form-input + v-model="enteredClusterName" + autofocus + type="text" + name="confirm_cluster_name_input" + autocomplete="off" + /> + </form> + <span v-if="confirmCleanup">{{ + s__( + 'ClusterIntegration|If you do not wish to delete all associated GitLab resources, you can simply remove the integration.', + ) + }}</span> + </template> + <template slot="modal-footer"> + <gl-button variant="secondary" @click="handleCancel">{{ s__('Cancel') }}</gl-button> + <template v-if="confirmCleanup"> + <gl-button :disabled="!canSubmit" variant="warning" @click="handleSubmit">{{ + s__('ClusterIntegration|Remove integration') + }}</gl-button> + <gl-button :disabled="!canSubmit" variant="danger" @click="handleSubmit(true)">{{ + s__('ClusterIntegration|Remove integration and resources') + }}</gl-button> + </template> + <template v-else> + <gl-button :disabled="!canSubmit" variant="danger" @click="handleSubmit">{{ + s__('ClusterIntegration|Remove integration') + }}</gl-button> + </template> + </template> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue index 125bcaacc1c..e33431d2ea1 100644 --- a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue +++ b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue @@ -1,7 +1,7 @@ <script> import { GlModal } from '@gitlab/ui'; -import { sprintf, s__ } from '~/locale'; import trackUninstallButtonClickMixin from 'ee_else_ce/clusters/mixins/track_uninstall_button_click'; +import { sprintf, s__ } from '~/locale'; import { HELM, INGRESS, diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js index 60c2059a876..a28e17f7a56 100644 --- a/app/assets/javascripts/commit/image_file.js +++ b/app/assets/javascripts/commit/image_file.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, no-else-return, consistent-return, one-var, no-return-assign */ +/* eslint-disable func-names, no-else-return, consistent-return, one-var, no-return-assign */ import $ from 'jquery'; @@ -51,7 +51,7 @@ export default class ImageFile { } // eslint-disable-next-line class-methods-use-this initDraggable($el, padding, callback) { - var dragging = false; + let dragging = false; const $body = $('body'); const $offsetEl = $el.parent(); const dragStart = function() { @@ -88,14 +88,12 @@ export default class ImageFile { } static prepareFrames(view) { - var maxHeight, maxWidth; - maxWidth = 0; - maxHeight = 0; + let maxWidth = 0; + let maxHeight = 0; $('.frame', view) .each((index, frame) => { - var height, width; - width = $(frame).width(); - height = $(frame).height(); + const width = $(frame).width(); + const height = $(frame).height(); maxWidth = width > maxWidth ? width : maxWidth; return (maxHeight = height > maxHeight ? height : maxHeight); }) @@ -110,8 +108,7 @@ export default class ImageFile { 'two-up': function() { return $('.two-up.view .wrap', this.file).each((index, wrap) => { $('img', wrap).each(function() { - var currentWidth; - currentWidth = $(this).width(); + const currentWidth = $(this).width(); if (currentWidth > availWidth / 2) { return $(this).width(availWidth / 2); } @@ -124,16 +121,14 @@ export default class ImageFile { }); }, swipe() { - var maxHeight, maxWidth; - maxWidth = 0; - maxHeight = 0; + let maxWidth = 0; + let maxHeight = 0; return $('.swipe.view', this.file).each((index, view) => { - var $swipeWrap, $swipeBar, $swipeFrame, wrapPadding; const ref = ImageFile.prepareFrames(view); [maxWidth, maxHeight] = ref; - $swipeFrame = $('.swipe-frame', view); - $swipeWrap = $('.swipe-wrap', view); - $swipeBar = $('.swipe-bar', view); + const $swipeFrame = $('.swipe-frame', view); + const $swipeWrap = $('.swipe-wrap', view); + const $swipeBar = $('.swipe-bar', view); $swipeFrame.css({ width: maxWidth + 16, @@ -148,7 +143,7 @@ export default class ImageFile { left: 1, }); - wrapPadding = parseInt($swipeWrap.css('right').replace('px', ''), 10); + const wrapPadding = parseInt($swipeWrap.css('right').replace('px', ''), 10); this.initDraggable($swipeBar, wrapPadding, (e, left) => { if (left > 0 && left < $swipeFrame.width() - wrapPadding * 2) { @@ -159,19 +154,17 @@ export default class ImageFile { }); }, 'onion-skin': function() { - var dragTrackWidth, maxHeight, maxWidth; + let maxHeight, maxWidth; maxWidth = 0; maxHeight = 0; - dragTrackWidth = $('.drag-track', this.file).width() - $('.dragger', this.file).width(); + const dragTrackWidth = $('.drag-track', this.file).width() - $('.dragger', this.file).width(); return $('.onion-skin.view', this.file).each((index, view) => { - var $frame, $track, $dragger, $frameAdded, framePadding; - const ref = ImageFile.prepareFrames(view); [maxWidth, maxHeight] = ref; - $frame = $('.onion-skin-frame', view); - $frameAdded = $('.frame.added', view); - $track = $('.drag-track', view); - $dragger = $('.dragger', $track); + const $frame = $('.onion-skin-frame', view); + const $frameAdded = $('.frame.added', view); + const $track = $('.drag-track', view); + const $dragger = $('.dragger', $track); $frame.css({ width: maxWidth + 16, @@ -186,10 +179,10 @@ export default class ImageFile { }); $frameAdded.css('opacity', 1); - framePadding = parseInt($frameAdded.css('right').replace('px', ''), 10); + const framePadding = parseInt($frameAdded.css('right').replace('px', ''), 10); this.initDraggable($dragger, framePadding, (e, left) => { - var opacity = left / dragTrackWidth; + const opacity = left / dragTrackWidth; if (opacity >= 0 && opacity <= 1) { $dragger.css('left', left); diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js index 7a6ad3dc771..dd300b8a307 100644 --- a/app/assets/javascripts/commons/polyfills.js +++ b/app/assets/javascripts/commons/polyfills.js @@ -4,6 +4,7 @@ import 'core-js/es/array/find'; import 'core-js/es/array/find-index'; import 'core-js/es/array/from'; import 'core-js/es/array/includes'; +import 'core-js/es/number/is-integer'; import 'core-js/es/object/assign'; import 'core-js/es/object/values'; import 'core-js/es/object/entries'; 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 4fa18b19556..f2853564f94 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 @@ -1,6 +1,6 @@ <script> -import { GlLink } from '@gitlab/ui'; -import { __, sprintf } from '../../locale'; +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { __ } from '../../locale'; import createFlash from '../../flash'; import Api from '../../api'; import state from '../state'; @@ -9,6 +9,7 @@ import Dropdown from './dropdown.vue'; export default { components: { GlLink, + GlSprintf, Dropdown, }, props: { @@ -38,15 +39,6 @@ export default { selectedProject() { return state.selectedProject; }, - noForkText() { - return sprintf( - __( - "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private.", - ), - { link_start: `<a href="${this.newForkPath}" class="help-link">`, link_end: '</a>' }, - false, - ); - }, }, mounted() { this.fetchProjects(); @@ -123,8 +115,20 @@ export default { }} </template> <template v-else> - {{ __('No forks available to you.') }}<br /> - <span v-html="noForkText"></span> + {{ __('No forks are available to you.') }}<br /> + <gl-sprintf + :message=" + __( + `To protect this issue's confidentiality, %{forkLink} and set the fork's visibility to private.`, + ) + " + > + <template #forkLink> + <a :href="newForkPath" target="_blank" class="help-link">{{ + __('fork this project') + }}</a> + </template> + </gl-sprintf> </template> <gl-link :href="helpPagePath" diff --git a/app/assets/javascripts/confirm_danger_modal.js b/app/assets/javascripts/confirm_danger_modal.js index 1000c310e35..262d501bfba 100644 --- a/app/assets/javascripts/confirm_danger_modal.js +++ b/app/assets/javascripts/confirm_danger_modal.js @@ -1,39 +1,54 @@ import $ from 'jquery'; import { rstrip } from './lib/utils/common_utils'; -function openConfirmDangerModal($form, text) { - const $input = $('.js-confirm-danger-input'); +function openConfirmDangerModal($form, $modal, text) { + const $input = $('.js-confirm-danger-input', $modal); $input.val(''); - $('.js-confirm-text').text(text || ''); - $('#modal-confirm-danger').modal('show'); + $('.js-confirm-text', $modal).text(text || ''); + $modal.modal('show'); - const confirmTextMatch = $('.js-confirm-danger-match').text(); - const $submit = $('.js-confirm-danger-submit'); + const confirmTextMatch = $('.js-confirm-danger-match', $modal).text(); + const $submit = $('.js-confirm-danger-submit', $modal); $submit.disable(); $input.focus(); - $('.js-confirm-danger-input') - .off('input') - .on('input', function handleInput() { - const confirmText = rstrip($(this).val()); - if (confirmText === confirmTextMatch) { - $submit.enable(); - } else { - $submit.disable(); - } - }); - $('.js-confirm-danger-submit') + $input.off('input').on('input', function handleInput() { + const confirmText = rstrip($(this).val()); + if (confirmText === confirmTextMatch) { + $submit.enable(); + } else { + $submit.disable(); + } + }); + $('.js-confirm-danger-submit', $modal) .off('click') .on('click', () => $form.submit()); } +function getModal($btn) { + const $modal = $btn.prev('.modal'); + + if ($modal.length) { + return $modal; + } + + return $('#modal-confirm-danger'); +} + export default function initConfirmDangerModal() { $(document).on('click', '.js-confirm-danger', e => { - e.preventDefault(); const $btn = $(e.target); - const $form = $btn.closest('form'); - const text = $btn.data('confirmDangerMessage'); - openConfirmDangerModal($form, text); + const checkFieldName = $btn.data('checkFieldName'); + const checkFieldCompareValue = $btn.data('checkCompareValue'); + const checkFieldVal = parseInt($(`[name="${checkFieldName}"]`).val(), 10); + + if (!checkFieldName || checkFieldVal < checkFieldCompareValue) { + e.preventDefault(); + const $form = $btn.closest('form'); + const $modal = getModal($btn); + const text = $btn.data('confirmDangerMessage'); + openConfirmDangerModal($form, $modal, text); + } }); } diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue index 7dd6b051cb4..fb7000ee9ed 100644 --- a/app/assets/javascripts/contributors/components/contributors.vue +++ b/app/assets/javascripts/contributors/components/contributors.vue @@ -1,9 +1,9 @@ <script> -import { __ } from '~/locale'; import _ from 'underscore'; import { mapActions, mapState, mapGetters } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import { __ } from '~/locale'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import { getDatesInRange } from '~/lib/utils/datetime_utility'; import { xAxisLabelFormatter, dateFormatter } from '../utils'; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue index e6893c14cda..2f7fcfcb755 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue @@ -1,8 +1,9 @@ <script> +import $ from 'jquery'; +import { GlIcon } from '@gitlab/ui'; import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_search_input.vue'; import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; -import { GlIcon } from '@gitlab/ui'; const toArray = value => [].concat(value); const itemsProp = (items, prop) => items.map(item => item[prop]); @@ -106,6 +107,7 @@ export default { data() { return { searchQuery: '', + focusOnSearch: false, }; }, computed: { @@ -141,6 +143,18 @@ export default { return itemsProp(this.selectedItems, this.valueProperty).join(', '); }, }, + mounted() { + $(this.$refs.dropdown) + .on('shown.bs.dropdown', () => { + this.focusOnSearch = true; + }) + .on('hidden.bs.dropdown', () => { + this.focusOnSearch = false; + }); + }, + beforeDestroy() { + $(this.$refs.dropdown).off(); + }, methods: { getItemsOrEmptyList() { return this.items || []; @@ -170,7 +184,7 @@ export default { <template> <div> - <div class="js-gcp-machine-type-dropdown dropdown"> + <div ref="dropdown" class="dropdown"> <dropdown-hidden-input :name="fieldName" :value="selectedItemsValues" /> <dropdown-button :class="{ 'border-danger': hasErrors }" @@ -179,7 +193,11 @@ export default { :toggle-text="toggleText" /> <div class="dropdown-menu dropdown-select"> - <dropdown-search-input v-model="searchQuery" :placeholder-text="searchFieldPlaceholder" /> + <dropdown-search-input + v-model="searchQuery" + :focused="focusOnSearch" + :placeholder-text="searchFieldPlaceholder" + /> <div class="dropdown-content"> <ul> <li v-if="!results.length"> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue index 57d5f4f541b..d04d0ff2a6d 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue @@ -1,8 +1,8 @@ <script> import { createNamespacedHelpers, mapState, mapActions } from 'vuex'; -import { sprintf, s__ } from '~/locale'; import _ from 'underscore'; import { GlFormInput, GlFormCheckbox } from '@gitlab/ui'; +import { sprintf, s__ } from '~/locale'; import ClusterFormDropdown from './cluster_form_dropdown.vue'; import { KUBERNETES_VERSIONS } from '../constants'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; @@ -22,10 +22,7 @@ const { mapState: mapSecurityGroupsState, mapActions: mapSecurityGroupsActions, } = createNamespacedHelpers('securityGroups'); -const { - mapState: mapInstanceTypesState, - mapActions: mapInstanceTypesActions, -} = createNamespacedHelpers('instanceTypes'); +const { mapState: mapInstanceTypesState } = createNamespacedHelpers('instanceTypes'); export default { components: { @@ -265,12 +262,10 @@ export default { mounted() { this.fetchRegions(); this.fetchRoles(); - this.fetchInstanceTypes(); }, methods: { ...mapActions([ 'createCluster', - 'signOut', 'setClusterName', 'setEnvironmentScope', 'setKubernetesVersion', @@ -290,7 +285,6 @@ export default { ...mapRolesActions({ fetchRoles: 'fetchItems' }), ...mapKeyPairsActions({ fetchKeyPairs: 'fetchItems' }), ...mapSecurityGroupsActions({ fetchSecurityGroups: 'fetchItems' }), - ...mapInstanceTypesActions({ fetchInstanceTypes: 'fetchItems' }), setRegionAndFetchVpcsAndKeyPairs(region) { this.setRegion({ region }); this.setVpc({ vpc: null }); @@ -316,11 +310,6 @@ export default { {{ s__('ClusterIntegration|Enter the details for your Amazon EKS Kubernetes cluster') }} </h2> <div class="mb-3" v-html="kubernetesIntegrationHelpText"></div> - <div class="mb-3"> - <button class="btn btn-link js-sign-out" @click.prevent="signOut()"> - {{ s__('ClusterIntegration|Select a different AWS role') }} - </button> - </div> <div class="form-group"> <label class="label-bold" for="eks-cluster-name">{{ s__('ClusterIntegration|Kubernetes cluster name') diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue index ab33e9fbc95..1dd4c468ae6 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue @@ -1,8 +1,8 @@ <script> import { GlFormInput } from '@gitlab/ui'; -import { sprintf, s__, __ } from '~/locale'; import _ from 'underscore'; import { mapState, mapActions } from 'vuex'; +import { sprintf, s__, __ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; @@ -28,7 +28,7 @@ export default { }, data() { return { - roleArn: '', + roleArn: this.$store.state.roleArn, }; }, computed: { diff --git a/app/assets/javascripts/create_cluster/eks_cluster/index.js b/app/assets/javascripts/create_cluster/eks_cluster/index.js index 27f859d8972..fb993a7aa59 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/index.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/index.js @@ -12,20 +12,14 @@ export default el => { kubernetesIntegrationHelpPath, accountAndExternalIdsHelpPath, createRoleArnHelpPath, - getRolesPath, - getRegionsPath, - getKeyPairsPath, - getVpcsPath, - getSubnetsPath, - getSecurityGroupsPath, - getInstanceTypesPath, externalId, accountId, + instanceTypes, hasCredentials, createRolePath, createClusterPath, - signOutPath, externalLinkIcon, + roleArn, } = el.dataset; return new Vue({ @@ -35,18 +29,10 @@ export default el => { hasCredentials: parseBoolean(hasCredentials), externalId, accountId, + instanceTypes: JSON.parse(instanceTypes), createRolePath, createClusterPath, - signOutPath, - }, - apiPaths: { - getRolesPath, - getRegionsPath, - getKeyPairsPath, - getVpcsPath, - getSubnetsPath, - getSecurityGroupsPath, - getInstanceTypesPath, + roleArn, }, }), components: { diff --git a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js index 21b87d525cf..601ff6f9adc 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js @@ -1,58 +1,98 @@ -import axios from '~/lib/utils/axios_utils'; - -export default apiPaths => ({ - fetchRoles() { - return axios - .get(apiPaths.getRolesPath) - .then(({ data: { roles } }) => - roles.map(({ role_name: name, arn: value }) => ({ name, value })), - ); - }, - fetchKeyPairs({ region }) { - return axios - .get(apiPaths.getKeyPairsPath, { params: { region } }) - .then(({ data: { key_pairs: keyPairs } }) => - keyPairs.map(({ key_name }) => ({ name: key_name, value: key_name })), - ); - }, - fetchRegions() { - return axios.get(apiPaths.getRegionsPath).then(({ data: { regions } }) => - regions.map(({ region_name }) => ({ - name: region_name, - value: region_name, +import AWS from 'aws-sdk/global'; +import EC2 from 'aws-sdk/clients/ec2'; +import IAM from 'aws-sdk/clients/iam'; + +const lookupVpcName = ({ Tags: tags, VpcId: id }) => { + const nameTag = tags.find(({ Key: key }) => key === 'Name'); + + return nameTag ? nameTag.Value : id; +}; + +export const DEFAULT_REGION = 'us-east-2'; + +export const setAWSConfig = ({ awsCredentials }) => { + AWS.config = { + ...awsCredentials, + region: DEFAULT_REGION, + }; +}; + +export const fetchRoles = () => { + const iam = new IAM(); + + return iam + .listRoles() + .promise() + .then(({ Roles: roles }) => roles.map(({ RoleName: name, Arn: value }) => ({ name, value }))); +}; + +export const fetchRegions = () => { + const ec2 = new EC2(); + + return ec2 + .describeRegions() + .promise() + .then(({ Regions: regions }) => + regions.map(({ RegionName: name }) => ({ + name, + value: name, })), ); - }, - fetchVpcs({ region }) { - return axios.get(apiPaths.getVpcsPath, { params: { region } }).then(({ data: { vpcs } }) => - vpcs.map(({ vpc_id }) => ({ - value: vpc_id, - name: vpc_id, +}; + +export const fetchKeyPairs = ({ region }) => { + const ec2 = new EC2({ region }); + + return ec2 + .describeKeyPairs() + .promise() + .then(({ KeyPairs: keyPairs }) => keyPairs.map(({ KeyName: name }) => ({ name, value: name }))); +}; + +export const fetchVpcs = ({ region }) => { + const ec2 = new EC2({ region }); + + return ec2 + .describeVpcs() + .promise() + .then(({ Vpcs: vpcs }) => + vpcs.map(vpc => ({ + value: vpc.VpcId, + name: lookupVpcName(vpc), })), ); - }, - fetchSubnets({ vpc, region }) { - return axios - .get(apiPaths.getSubnetsPath, { params: { vpc_id: vpc, region } }) - .then(({ data: { subnets } }) => - subnets.map(({ subnet_id }) => ({ name: subnet_id, value: subnet_id })), - ); - }, - fetchSecurityGroups({ vpc, region }) { - return axios - .get(apiPaths.getSecurityGroupsPath, { params: { vpc_id: vpc, region } }) - .then(({ data: { security_groups: securityGroups } }) => - securityGroups.map(({ group_name: name, group_id: value }) => ({ name, value })), - ); - }, - fetchInstanceTypes() { - return axios - .get(apiPaths.getInstanceTypesPath) - .then(({ data: { instance_types: instanceTypes } }) => - instanceTypes.map(({ instance_type_name }) => ({ - name: instance_type_name, - value: instance_type_name, - })), - ); - }, -}); +}; + +export const fetchSubnets = ({ vpc, region }) => { + const ec2 = new EC2({ region }); + + return ec2 + .describeSubnets({ + Filters: [ + { + Name: 'vpc-id', + Values: [vpc], + }, + ], + }) + .promise() + .then(({ Subnets: subnets }) => subnets.map(({ SubnetId: id }) => ({ value: id, name: id }))); +}; + +export const fetchSecurityGroups = ({ region, vpc }) => { + const ec2 = new EC2({ region }); + + return ec2 + .describeSecurityGroups({ + Filters: [ + { + Name: 'vpc-id', + Values: [vpc], + }, + ], + }) + .promise() + .then(({ SecurityGroups: securityGroups }) => + securityGroups.map(({ GroupName: name, GroupId: value }) => ({ name, value })), + ); +}; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js index 72f15263a8f..e96e6d6e4f8 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js @@ -1,6 +1,8 @@ import * as types from './mutation_types'; +import { setAWSConfig } from '../services/aws_services_facade'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; const getErrorMessage = data => { const errorKey = Object.keys(data)[0]; @@ -28,7 +30,7 @@ export const createRole = ({ dispatch, state: { createRolePath } }, payload) => role_arn: payload.roleArn, role_external_id: payload.externalId, }) - .then(() => dispatch('createRoleSuccess')) + .then(({ data }) => dispatch('createRoleSuccess', convertObjectPropsToCamelCase(data))) .catch(error => dispatch('createRoleError', { error })); }; @@ -36,7 +38,8 @@ export const requestCreateRole = ({ commit }) => { commit(types.REQUEST_CREATE_ROLE); }; -export const createRoleSuccess = ({ commit }) => { +export const createRoleSuccess = ({ commit }, awsCredentials) => { + setAWSConfig({ awsCredentials }); commit(types.CREATE_ROLE_SUCCESS); }; @@ -117,9 +120,3 @@ export const setInstanceType = ({ commit }, payload) => { export const setNodeCount = ({ commit }, payload) => { commit(types.SET_NODE_COUNT, payload); }; - -export const signOut = ({ commit, state: { signOutPath } }) => - axios - .delete(signOutPath) - .then(() => commit(types.SIGN_OUT)) - .catch(({ response: { data } }) => createFlash(getErrorMessage(data))); diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js index 07a5821c47d..0b19589215c 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js @@ -3,11 +3,11 @@ import actions from './actions'; import mutations from './mutations'; import state from './state'; -const createStore = fetchFn => ({ +const createStore = ({ fetchFn, initialState }) => ({ actions: actions(fetchFn), getters, mutations, - state: state(), + state: Object.assign(state(), initialState || {}), }); export default createStore; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js index 5982fc8a2fd..09fd560240d 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js @@ -6,12 +6,17 @@ import state from './state'; import clusterDropdownStore from './cluster_dropdown'; -import awsServicesFactory from '../services/aws_services_facade'; +import { + fetchRoles, + fetchRegions, + fetchKeyPairs, + fetchVpcs, + fetchSubnets, + fetchSecurityGroups, +} from '../services/aws_services_facade'; -const createStore = ({ initialState, apiPaths }) => { - const awsServices = awsServicesFactory(apiPaths); - - return new Vuex.Store({ +const createStore = ({ initialState }) => + new Vuex.Store({ actions, getters, mutations, @@ -19,34 +24,33 @@ const createStore = ({ initialState, apiPaths }) => { modules: { roles: { namespaced: true, - ...clusterDropdownStore(awsServices.fetchRoles), + ...clusterDropdownStore({ fetchFn: fetchRoles }), }, regions: { namespaced: true, - ...clusterDropdownStore(awsServices.fetchRegions), + ...clusterDropdownStore({ fetchFn: fetchRegions }), }, keyPairs: { namespaced: true, - ...clusterDropdownStore(awsServices.fetchKeyPairs), + ...clusterDropdownStore({ fetchFn: fetchKeyPairs }), }, vpcs: { namespaced: true, - ...clusterDropdownStore(awsServices.fetchVpcs), + ...clusterDropdownStore({ fetchFn: fetchVpcs }), }, subnets: { namespaced: true, - ...clusterDropdownStore(awsServices.fetchSubnets), + ...clusterDropdownStore({ fetchFn: fetchSubnets }), }, securityGroups: { namespaced: true, - ...clusterDropdownStore(awsServices.fetchSecurityGroups), + ...clusterDropdownStore({ fetchFn: fetchSecurityGroups }), }, instanceTypes: { namespaced: true, - ...clusterDropdownStore(awsServices.fetchInstanceTypes), + ...clusterDropdownStore({ initialState: { items: initialState.instanceTypes } }), }, }, }); -}; export default createStore; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js index f9204cc2207..9dee6abae5f 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js @@ -13,7 +13,6 @@ export const SET_GITLAB_MANAGED_CLUSTER = 'SET_GITLAB_MANAGED_CLUSTER'; export const REQUEST_CREATE_ROLE = 'REQUEST_CREATE_ROLE'; export const CREATE_ROLE_SUCCESS = 'CREATE_ROLE_SUCCESS'; export const CREATE_ROLE_ERROR = 'CREATE_ROLE_ERROR'; -export const SIGN_OUT = 'SIGN_OUT'; export const REQUEST_CREATE_CLUSTER = 'REQUEST_CREATE_CLUSTER'; export const CREATE_CLUSTER_SUCCESS = 'CREATE_CLUSTER_SUCCESS'; export const CREATE_CLUSTER_ERROR = 'CREATE_CLUSTER_ERROR'; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js index aa04c8f7079..c331d27d255 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js @@ -60,7 +60,4 @@ export default { state.isCreatingCluster = false; state.createClusterError = error; }, - [types.SIGN_OUT](state) { - state.hasCredentials = false; - }, }; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js index 2e3a05a9187..20434dcce98 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js @@ -12,6 +12,8 @@ export default () => ({ accountId: '', externalId: '', + roleArn: '', + clusterName: '', environmentScope: '*', kubernetesVersion, diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_dropdown_mixin.js b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_dropdown_mixin.js index 5a3407693e5..43fd0cac3be 100644 --- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_dropdown_mixin.js +++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_dropdown_mixin.js @@ -1,8 +1,8 @@ import _ from 'underscore'; +import { GlLoadingIcon } from '@gitlab/ui'; import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_search_input.vue'; import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; -import { GlLoadingIcon } from '@gitlab/ui'; import store from '../store'; diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue index 83811ab489a..a9d9f0224e3 100644 --- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue +++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue @@ -1,6 +1,6 @@ <script> -import { sprintf, s__ } from '~/locale'; import { mapState, mapGetters, mapActions } from 'vuex'; +import { sprintf, s__ } from '~/locale'; import gkeDropdownMixin from './gke_dropdown_mixin'; diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue index a2eb79af4f9..6815d3629e3 100644 --- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue +++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue @@ -1,7 +1,7 @@ <script> import _ from 'underscore'; -import { s__, sprintf } from '~/locale'; import { mapState, mapGetters, mapActions } from 'vuex'; +import { s__, sprintf } from '~/locale'; import gkeDropdownMixin from './gke_dropdown_mixin'; diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue index fd5d5f86401..b60a5be2e63 100644 --- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue +++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue @@ -1,6 +1,6 @@ <script> -import { sprintf, s__ } from '~/locale'; import { mapState, mapActions } from 'vuex'; +import { sprintf, s__ } from '~/locale'; import gkeDropdownMixin from './gke_dropdown_mixin'; diff --git a/app/assets/javascripts/create_cluster/init_create_cluster.js b/app/assets/javascripts/create_cluster/init_create_cluster.js index 7c984582fd8..2b09771d772 100644 --- a/app/assets/javascripts/create_cluster/init_create_cluster.js +++ b/app/assets/javascripts/create_cluster/init_create_cluster.js @@ -6,7 +6,7 @@ const newClusterViews = [':clusters:new', ':clusters:create_gcp', ':clusters:cre const isProjectLevelCluster = page => page.startsWith('project:clusters'); -export default (document, gon) => { +export default document => { const { page } = document.body.dataset; const isNewClusterView = newClusterViews.some(view => page.endsWith(view)); @@ -19,17 +19,15 @@ export default (document, gon) => { initGkeDropdowns(); - if (gon.features.createEksClusters) { - import(/* webpackChunkName: 'eks_cluster' */ '~/create_cluster/eks_cluster') - .then(({ default: initCreateEKSCluster }) => { - const el = document.querySelector('.js-create-eks-cluster-form-container'); + import(/* webpackChunkName: 'eks_cluster' */ '~/create_cluster/eks_cluster') + .then(({ default: initCreateEKSCluster }) => { + const el = document.querySelector('.js-create-eks-cluster-form-container'); - if (el) { - initCreateEKSCluster(el); - } - }) - .catch(() => {}); - } + if (el) { + initCreateEKSCluster(el); + } + }) + .catch(() => {}); if (isProjectLevelCluster(page)) { initGkeNamespace(); diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js index dce9c1a5410..d9805e5e76a 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -311,6 +311,7 @@ export default class CreateMergeRequestDropdown { } onChangeInput(event) { + this.disable(); let target; let value; diff --git a/app/assets/javascripts/cycle_analytics/components/banner.vue b/app/assets/javascripts/cycle_analytics/components/banner.vue index e44588efbfc..ae8c430dcd6 100644 --- a/app/assets/javascripts/cycle_analytics/components/banner.vue +++ b/app/assets/javascripts/cycle_analytics/components/banner.vue @@ -1,6 +1,6 @@ <script> -import Icon from '~/vue_shared/components/icon.vue'; import iconCycleAnalyticsSplash from 'icons/_icon_cycle_analytics_splash.svg'; +import Icon from '~/vue_shared/components/icon.vue'; export default { components: { diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index 922c907bb36..048f3a2485c 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -1,4 +1,5 @@ <script> +import { GlLoadingIcon } from '@gitlab/ui'; import { s__ } from '~/locale'; import Flash from '~/flash'; import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; @@ -6,7 +7,6 @@ import eventHub from '../eventhub'; import DeployKeysService from '../service'; import DeployKeysStore from '../store'; import KeysPanel from './keys_panel.vue'; -import { GlLoadingIcon } from '@gitlab/ui'; export default { components: { @@ -133,7 +133,7 @@ export default { :keys="keys[currentTab]" :store="store" :endpoint="endpoint" - class="qa-project-deploy-keys" + data-qa-selector="project_deploy_keys" /> </template> </div> diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue index 6ffb8c4e1c0..4d36a492c1c 100644 --- a/app/assets/javascripts/deploy_keys/components/key.vue +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -159,7 +159,7 @@ export default { <div role="rowheader" class="table-mobile-header">{{ __('Created') }}</div> <div class="table-mobile-content text-secondary key-created-at"> <span v-tooltip :title="tooltipTitle(deployKey.created_at)"> - <icon name="calendar" /> <span>{{ timeFormated(deployKey.created_at) }}</span> + <icon name="calendar" /> <span>{{ timeFormatted(deployKey.created_at) }}</span> </span> </div> </div> diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 19b85710710..8ea443814e9 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -1,11 +1,12 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import Mousetrap from 'mousetrap'; import Icon from '~/vue_shared/components/icon.vue'; import { __ } from '~/locale'; import createFlash from '~/flash'; -import { GlLoadingIcon } from '@gitlab/ui'; import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; -import Mousetrap from 'mousetrap'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../../notes/event_hub'; import CompareVersions from './compare_versions.vue'; import DiffFile from './diff_file.vue'; @@ -36,11 +37,20 @@ export default { GlLoadingIcon, PanelResizer, }, + mixins: [glFeatureFlagsMixin()], props: { endpoint: { type: String, required: true, }, + endpointMetadata: { + type: String, + required: true, + }, + endpointBatch: { + type: String, + required: true, + }, projectPath: { type: String, required: true, @@ -92,6 +102,7 @@ export default { computed: { ...mapState({ isLoading: state => state.diffs.isLoading, + isBatchLoading: state => state.diffs.isBatchLoading, diffFiles: state => state.diffs.diffFiles, diffViewType: state => state.diffs.diffViewType, mergeRequestDiffs: state => state.diffs.mergeRequestDiffs, @@ -133,6 +144,9 @@ export default { isLimitedContainer() { return !this.showTreeList && !this.isParallelView && !this.isFluidLayout; }, + shouldSetDiscussions() { + return this.isNotesFetched && !this.assignedDiscussions && !this.isLoading; + }, }, watch: { diffViewType() { @@ -149,13 +163,21 @@ export default { }, isLoading: 'adjustView', showTreeList: 'adjustView', + shouldSetDiscussions(newVal) { + if (newVal) { + this.setDiscussions(); + } + }, }, mounted() { this.setBaseConfig({ endpoint: this.endpoint, + endpointMetadata: this.endpointMetadata, + endpointBatch: this.endpointBatch, projectPath: this.projectPath, dismissEndpoint: this.dismissEndpoint, showSuggestPopover: this.showSuggestPopover, + useSingleDiffStyle: this.glFeatures.singleMrDiffView, }); if (this.shouldShow) { @@ -185,6 +207,8 @@ export default { ...mapActions('diffs', [ 'setBaseConfig', 'fetchDiffFiles', + 'fetchDiffFilesMeta', + 'fetchDiffFilesBatch', 'startRenderDiffsQueue', 'assignDiscussionsToDiff', 'setHighlightedRow', @@ -196,31 +220,56 @@ export default { this.assignedDiscussions = false; this.fetchData(false); }, + startDiffRendering() { + requestIdleCallback( + () => { + this.startRenderDiffsQueue(); + }, + { timeout: 1000 }, + ); + }, fetchData(toggleTree = true) { - this.fetchDiffFiles() - .then(() => { - if (toggleTree) { - this.hideTreeListIfJustOneFile(); - } + if (this.glFeatures.diffsBatchLoad) { + this.fetchDiffFilesMeta() + .then(() => { + if (toggleTree) this.hideTreeListIfJustOneFile(); - requestIdleCallback( - () => { - this.setDiscussions(); - this.startRenderDiffsQueue(); - }, - { timeout: 1000 }, - ); - }) - .catch(() => { - createFlash(__('Something went wrong on our end. Please try again!')); - }); + this.startDiffRendering(); + }) + .catch(() => { + createFlash(__('Something went wrong on our end. Please try again!')); + }); + + this.fetchDiffFilesBatch() + .then(() => this.startDiffRendering()) + .catch(() => { + createFlash(__('Something went wrong on our end. Please try again!')); + }); + } else { + this.fetchDiffFiles() + .then(() => { + if (toggleTree) { + this.hideTreeListIfJustOneFile(); + } + + requestIdleCallback( + () => { + this.startRenderDiffsQueue(); + }, + { timeout: 1000 }, + ); + }) + .catch(() => { + createFlash(__('Something went wrong on our end. Please try again!')); + }); + } if (!this.isNotesFetched) { eventHub.$emit('fetchNotesData'); } }, setDiscussions() { - if (this.isNotesFetched && !this.assignedDiscussions && !this.isLoading) { + if (this.shouldSetDiscussions) { this.assignedDiscussions = true; requestIdleCallback( @@ -324,7 +373,8 @@ export default { }" > <commit-widget v-if="commit" :commit="commit" /> - <template v-if="renderDiffFiles"> + <div v-if="isBatchLoading" class="loading"><gl-loading-icon /></div> + <template v-else-if="renderDiffFiles"> <diff-file v-for="file in diffFiles" :key="file.newPath" diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue index 839ab542377..23fbfc2b74b 100644 --- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue @@ -1,7 +1,7 @@ <script> +import { mapState, mapActions } from 'vuex'; 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'; @@ -226,7 +226,7 @@ export default { <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> + <span>{{ s__('Diffs|Show unchanged lines') }}</span> </a> <a v-if="canExpandDown" diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 9236f0d5349..0dbff4ffcec 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -1,9 +1,9 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import _ from 'underscore'; +import { GlLoadingIcon } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import createFlash from '~/flash'; -import { GlLoadingIcon } from '@gitlab/ui'; import eventHub from '../../notes/event_hub'; import DiffFileHeader from './diff_file_header.vue'; import DiffContent from './diff_content.vue'; diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 665328eb234..91d374eafc0 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -1,17 +1,17 @@ <script> import _ from 'underscore'; import { mapActions, mapGetters } from 'vuex'; -import { polyfillSticky, stickyMonitor } from '~/lib/utils/sticky'; +import { GlButton, GlTooltipDirective, GlTooltip, GlLoadingIcon } from '@gitlab/ui'; +import { polyfillSticky } from '~/lib/utils/sticky'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import Icon from '~/vue_shared/components/icon.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; -import { GlButton, GlTooltipDirective, GlTooltip, GlLoadingIcon } from '@gitlab/ui'; import { truncateSha } from '~/lib/utils/text_utility'; import { __, s__, sprintf } from '~/locale'; import { diffViewerModes } from '~/ide/constants'; import EditButton from './edit_button.vue'; import DiffStats from './diff_stats.vue'; -import { scrollToElement, contentTop } from '~/lib/utils/common_utils'; +import { scrollToElement } from '~/lib/utils/common_utils'; export default { components: { @@ -127,8 +127,6 @@ export default { }, mounted() { polyfillSticky(this.$refs.header); - const fileHeaderHeight = this.$refs.header.clientHeight; - stickyMonitor(this.$refs.header, contentTop() - fileHeaderHeight - 1, false); }, methods: { ...mapActions('diffs', [ diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue index 7ede7a4f430..be19d8520b5 100644 --- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue +++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue @@ -1,9 +1,9 @@ <script> +import { GlTooltipDirective } from '@gitlab/ui'; import { n__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; 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'; export default { 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 434d554d148..34aa15856d2 100644 --- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue +++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue @@ -72,7 +72,7 @@ export default { lineCode() { return ( this.line.line_code || - (this.line.left && this.line.line.left.line_code) || + (this.line.left && this.line.left.line_code) || (this.line.right && this.line.right.line_code) ); }, diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue index da0cdbe467b..f81f50f8490 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -1,7 +1,7 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; -import { s__ } from '~/locale'; import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form'; +import { s__ } from '~/locale'; import noteForm from '../../notes/components/note_form.vue'; import autosave from '../../notes/mixins/autosave'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index d84e1af11f3..7521f3c950a 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -57,3 +57,4 @@ export const MIN_RENDERING_MS = 2; export const START_RENDERING_INDEX = 200; export const INLINE_DIFF_LINES_KEY = 'highlighted_diff_lines'; export const PARALLEL_DIFF_LINES_KEY = 'parallel_diff_lines'; +export const DIFFS_PER_PAGE = 20; diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index c9580e3d3b4..375ac80021f 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -67,6 +67,8 @@ export default function initDiffsApp(store) { return { endpoint: dataset.endpoint, + endpointMetadata: dataset.endpointMetadata || '', + endpointBatch: dataset.endpointBatch || '', projectPath: dataset.projectPath, helpPagePath: dataset.helpPagePath, currentUser: JSON.parse(dataset.currentUserData) || {}, @@ -100,6 +102,8 @@ export default function initDiffsApp(store) { return createElement('diffs-app', { props: { endpoint: this.endpoint, + endpointMetadata: this.endpointMetadata, + endpointBatch: this.endpointBatch, currentUser: this.currentUser, projectPath: this.projectPath, helpPagePath: this.helpPagePath, diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 6695d9fe96c..992b45c97ac 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import axios from '~/lib/utils/axios_utils'; import Cookies from 'js-cookie'; +import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; import { s__ } from '~/locale'; import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils'; @@ -13,6 +13,7 @@ import { convertExpandLines, idleCallback, allDiscussionWrappersExpanded, + prepareDiffData, } from './utils'; import * as types from './mutation_types'; import { @@ -33,16 +34,36 @@ import { START_RENDERING_INDEX, INLINE_DIFF_LINES_KEY, PARALLEL_DIFF_LINES_KEY, + DIFFS_PER_PAGE, } from '../constants'; import { diffViewerModes } from '~/ide/constants'; export const setBaseConfig = ({ commit }, options) => { - const { endpoint, projectPath, dismissEndpoint, showSuggestPopover } = options; - commit(types.SET_BASE_CONFIG, { endpoint, projectPath, dismissEndpoint, showSuggestPopover }); + const { + endpoint, + endpointMetadata, + endpointBatch, + projectPath, + dismissEndpoint, + showSuggestPopover, + useSingleDiffStyle, + } = options; + commit(types.SET_BASE_CONFIG, { + endpoint, + endpointMetadata, + endpointBatch, + projectPath, + dismissEndpoint, + showSuggestPopover, + useSingleDiffStyle, + }); }; export const fetchDiffFiles = ({ state, commit }) => { const worker = new TreeWorker(); + const urlParams = { + w: state.showWhitespace ? '0' : '1', + }; commit(types.SET_LOADING, true); @@ -53,9 +74,10 @@ export const fetchDiffFiles = ({ state, commit }) => { }); return axios - .get(mergeUrlParams({ w: state.showWhitespace ? '0' : '1' }, state.endpoint)) + .get(mergeUrlParams(urlParams, state.endpoint)) .then(res => { commit(types.SET_LOADING, false); + commit(types.SET_MERGE_REQUEST_DIFFS, res.data.merge_request_diffs || []); commit(types.SET_DIFF_DATA, res.data); @@ -67,6 +89,52 @@ export const fetchDiffFiles = ({ state, commit }) => { .catch(() => worker.terminate()); }; +export const fetchDiffFilesBatch = ({ commit, state }) => { + commit(types.SET_BATCH_LOADING, true); + + const getBatch = page => + axios + .get(state.endpointBatch, { + params: { page, per_page: DIFFS_PER_PAGE, w: state.showWhitespace ? '0' : '1' }, + }) + .then(({ data: { pagination, diff_files } }) => { + commit(types.SET_DIFF_DATA_BATCH, { diff_files }); + commit(types.SET_BATCH_LOADING, false); + return pagination.next_page; + }) + .then(nextPage => nextPage && getBatch(nextPage)); + + return getBatch() + .then(handleLocationHash) + .catch(() => null); +}; + +export const fetchDiffFilesMeta = ({ commit, state }) => { + const worker = new TreeWorker(); + + commit(types.SET_LOADING, true); + + worker.addEventListener('message', ({ data }) => { + commit(types.SET_TREE_DATA, data); + + worker.terminate(); + }); + + return axios + .get(state.endpointMetadata) + .then(({ data }) => { + const strippedData = { ...data }; + delete strippedData.diff_files; + commit(types.SET_LOADING, false); + commit(types.SET_MERGE_REQUEST_DIFFS, data.merge_request_diffs || []); + commit(types.SET_DIFF_DATA, strippedData); + + prepareDiffData(data); + worker.postMessage(data.diff_files); + }) + .catch(() => worker.terminate()); +}; + export const setHighlightedRow = ({ commit }, lineCode) => { const fileHash = lineCode.split('_')[0]; commit(types.SET_HIGHLIGHTED_ROW, lineCode); diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index 6821c8445ea..7366c50752c 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -8,6 +8,7 @@ const defaultViewType = INLINE_DIFF_VIEW_TYPE; export default () => ({ isLoading: true, + isBatchLoading: false, addedLines: null, removedLines: null, endpoint: '', @@ -30,4 +31,5 @@ export default () => ({ fileFinderVisible: false, dismissEndpoint: '', showSuggestPopover: true, + useSingleDiffStyle: false, }); diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index 9db56331faa..5a90d78b2bc 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -1,6 +1,8 @@ export const SET_BASE_CONFIG = 'SET_BASE_CONFIG'; export const SET_LOADING = 'SET_LOADING'; +export const SET_BATCH_LOADING = 'SET_BATCH_LOADING'; export const SET_DIFF_DATA = 'SET_DIFF_DATA'; +export const SET_DIFF_DATA_BATCH = 'SET_DIFF_DATA_BATCH'; export const SET_DIFF_VIEW_TYPE = 'SET_DIFF_VIEW_TYPE'; export const SET_MERGE_REQUEST_DIFFS = 'SET_MERGE_REQUEST_DIFFS'; export const TOGGLE_LINE_HAS_FORM = 'TOGGLE_LINE_HAS_FORM'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index a6915a46c00..859f43b3b6d 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -12,22 +12,57 @@ import * as types from './mutation_types'; export default { [types.SET_BASE_CONFIG](state, options) { - const { endpoint, projectPath, dismissEndpoint, showSuggestPopover } = options; - Object.assign(state, { endpoint, projectPath, dismissEndpoint, showSuggestPopover }); + const { + endpoint, + endpointMetadata, + endpointBatch, + projectPath, + dismissEndpoint, + showSuggestPopover, + useSingleDiffStyle, + } = options; + Object.assign(state, { + endpoint, + endpointMetadata, + endpointBatch, + projectPath, + dismissEndpoint, + showSuggestPopover, + useSingleDiffStyle, + }); }, [types.SET_LOADING](state, isLoading) { Object.assign(state, { isLoading }); }, + [types.SET_BATCH_LOADING](state, isBatchLoading) { + Object.assign(state, { isBatchLoading }); + }, + [types.SET_DIFF_DATA](state, data) { - prepareDiffData(data); + if ( + !( + gon && + gon.features && + gon.features.diffsBatchLoad && + window.location.search.indexOf('diff_id') === -1 + ) + ) { + prepareDiffData(data); + } Object.assign(state, { ...convertObjectPropsToCamelCase(data), }); }, + [types.SET_DIFF_DATA_BATCH](state, data) { + prepareDiffData(data); + + state.diffFiles.push(...data.diff_files); + }, + [types.RENDER_FILE](state, file) { Object.assign(file, { renderIt: true, diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index d46bdea9b50..281a0de1fc2 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -252,10 +252,11 @@ export function prepareDiffData(diffData) { showingLines += file.parallel_diff_lines.length; } + const name = (file.viewer && file.viewer.name) || diffViewerModes.text; + Object.assign(file, { renderIt: showingLines < LINES_TO_BE_RENDERED_DIRECTLY, - collapsed: - file.viewer.name === diffViewerModes.text && showingLines > MAX_LINES_TO_BE_RENDERED, + collapsed: name === diffViewerModes.text && showingLines > MAX_LINES_TO_BE_RENDERED, isShowingFullFile: false, isLoadingFullFile: false, discussions: [], @@ -497,7 +498,7 @@ export const allDiscussionWrappersExpanded = diff => { } }); } else if (diff.highlighted_diff_lines) { - diff.parallel_diff_lines.forEach(line => { + diff.highlighted_diff_lines.forEach(line => { if (line.discussions.length) { discussionsExpandedArray.push(line.discussionsExpanded); } diff --git a/app/assets/javascripts/emoji/no_emoji_validator.js b/app/assets/javascripts/emoji/no_emoji_validator.js index 384d62a133a..edef868619a 100644 --- a/app/assets/javascripts/emoji/no_emoji_validator.js +++ b/app/assets/javascripts/emoji/no_emoji_validator.js @@ -1,5 +1,5 @@ -import { __ } from '~/locale'; import emojiRegex from 'emoji-regex'; +import { __ } from '~/locale'; import InputValidator from '../validators/input_validator'; export default class NoEmojiValidator extends InputValidator { diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue index 426bb63d4f7..cdf62259479 100644 --- a/app/assets/javascripts/environments/components/container.vue +++ b/app/assets/javascripts/environments/components/container.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; -import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; import containerMixin from 'ee_else_ce/environments/mixins/container_mixin'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; import EnvironmentTable from '../components/environments_table.vue'; export default { diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index 22bba21526c..d2978422224 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -1,10 +1,10 @@ <script> +import { GlLoadingIcon } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; import { formatTime } from '~/lib/utils/datetime_utility'; import Icon from '~/vue_shared/components/icon.vue'; import eventHub from '../event_hub'; import tooltip from '../../vue_shared/directives/tooltip'; -import { GlLoadingIcon } from '@gitlab/ui'; export default { directives: { diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index c94039326aa..428dfe5fcf7 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -1,12 +1,13 @@ <script> /* eslint-disable @gitlab/vue-i18n/no-bare-strings */ -import { __, sprintf } from '~/locale'; -import Timeago from 'timeago.js'; +import { format } from 'timeago.js'; import _ from 'underscore'; import { GlTooltipDirective } from '@gitlab/ui'; +import environmentItemMixin from 'ee_else_ce/environments/mixins/environment_item_mixin'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import Icon from '~/vue_shared/components/icon.vue'; -import environmentItemMixin from 'ee_else_ce/environments/mixins/environment_item_mixin'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import { __, sprintf } from '~/locale'; import ActionsComponent from './environment_actions.vue'; import ExternalUrlComponent from './environment_external_url.vue'; import StopComponent from './environment_stop.vue'; @@ -22,11 +23,9 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; * * Renders a table row for each environment. */ -const timeagoInstance = new Timeago(); export default { components: { - UserAvatarLink, CommitComponent, Icon, ActionsComponent, @@ -35,6 +34,8 @@ export default { RollbackComponent, TerminalButtonComponent, MonitoringButtonComponent, + TooltipOnTruncate, + UserAvatarLink, }, directives: { GlTooltip: GlTooltipDirective, @@ -42,16 +43,21 @@ export default { mixins: [environmentItemMixin], props: { + canReadEnvironment: { + type: Boolean, + required: false, + default: false, + }, + model: { type: Object, required: true, default: () => ({}), }, - canReadEnvironment: { - type: Boolean, - required: false, - default: false, + tableData: { + type: Object, + required: true, }, }, @@ -121,7 +127,7 @@ export default { */ deployedDate() { if (this.canShowDate) { - return timeagoInstance.format(this.model.last_deployment.deployed_at); + return format(this.model.last_deployment.deployed_at); } return ''; }, @@ -446,9 +452,13 @@ export default { class="gl-responsive-table-row" role="row" > - <div class="table-section section-wrap section-15 text-truncate" role="gridcell"> + <div + class="table-section section-wrap text-truncate" + :class="tableData.name.spacing" + role="gridcell" + > <div v-if="!model.isFolder" class="table-mobile-header" role="rowheader"> - {{ s__('Environments|Environment') }} + {{ tableData.name.title }} </div> <span v-if="shouldRenderDeployBoard" class="deploy-board-icon" @click="toggleDeployBoard"> @@ -488,7 +498,8 @@ export default { </div> <div - class="table-section section-10 deployment-column d-none d-sm-none d-md-block" + class="table-section deployment-column d-none d-sm-none d-md-block" + :class="tableData.deploy.spacing" role="gridcell" > <span v-if="shouldRenderDeploymentID" class="text-break-word"> @@ -507,18 +518,32 @@ export default { </span> </div> - <div class="table-section section-15 d-none d-sm-none d-md-block" role="gridcell"> - <a - v-if="shouldRenderBuildName" - :href="buildPath" - class="build-link cgray flex-truncate-parent" - > - <span class="flex-truncate-child">{{ buildName }}</span> + <div + class="table-section d-none d-sm-none d-md-block" + :class="tableData.build.spacing" + role="gridcell" + > + <a v-if="shouldRenderBuildName" :href="buildPath" class="build-link cgray"> + <tooltip-on-truncate + :title="buildName" + truncate-target="child" + class="flex-truncate-parent" + > + <span class="flex-truncate-child"> + {{ buildName }} + </span> + </tooltip-on-truncate> </a> </div> - <div v-if="!model.isFolder" class="table-section section-20" role="gridcell"> - <div role="rowheader" class="table-mobile-header">{{ s__('Environments|Commit') }}</div> + <div + v-if="!model.isFolder" + class="table-section" + :class="tableData.commit.spacing" + role="gridcell" + > + <div role="rowheader" class="table-mobile-header">{{ tableData.commit.title }}</div> + <div v-if="hasLastDeploymentKey" class="js-commit-component table-mobile-content"> <commit-component :tag="commitTag" @@ -534,8 +559,14 @@ export default { </div> </div> - <div v-if="!model.isFolder" class="table-section section-10" role="gridcell"> - <div role="rowheader" class="table-mobile-header">{{ s__('Environments|Updated') }}</div> + <div + v-if="!model.isFolder" + class="table-section" + :class="tableData.date.spacing" + role="gridcell" + > + <div role="rowheader" class="table-mobile-header">{{ tableData.date.title }}</div> + <span v-if="canShowDate" class="environment-created-date-timeago table-mobile-content"> {{ deployedDate }} </span> @@ -543,7 +574,8 @@ export default { <div v-if="!model.isFolder && displayEnvironmentActions" - class="table-section section-30 table-button-footer" + class="table-section table-button-footer" + :class="tableData.actions.spacing" role="gridcell" > <div class="btn-group table-action-buttons" role="group"> diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue index 886490847ea..7b4b633dc7f 100644 --- a/app/assets/javascripts/environments/components/environment_monitoring.vue +++ b/app/assets/javascripts/environments/components/environment_monitoring.vue @@ -1,9 +1,9 @@ <script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; /** * Renders the Monitoring (Metrics) link in environments table. */ -import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; export default { diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index 81927d18f8b..50c667e6966 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -31,10 +31,6 @@ export default { type: Boolean, required: true, }, - cssContainerClass: { - type: String, - required: true, - }, newEnvironmentPath: { type: String, required: true, @@ -93,7 +89,7 @@ export default { }; </script> <template> - <div :class="cssContainerClass"> + <div> <stop-environment-modal :environment="environmentInStopModal" /> <confirm-rollback-modal :environment="environmentInRollbackModal" /> diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 4464f5e5578..453e7610e21 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -5,6 +5,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import _ from 'underscore'; import environmentTableMixin from 'ee_else_ce/environments/mixins/environments_table_mixin'; +import { s__ } from '~/locale'; import EnvironmentItem from './environment_item.vue'; export default { @@ -41,6 +42,34 @@ export default { : env, ); }, + tableData() { + return { + // percent spacing for cols, should add up to 100 + name: { + title: s__('Environments|Environment'), + spacing: 'section-15', + }, + deploy: { + title: s__('Environments|Deployment'), + spacing: 'section-10', + }, + build: { + title: s__('Environments|Job'), + spacing: 'section-15', + }, + commit: { + title: s__('Environments|Commit'), + spacing: 'section-20', + }, + date: { + title: s__('Environments|Updated'), + spacing: 'section-10', + }, + actions: { + spacing: 'section-30', + }, + }; + }, }, methods: { folderUrl(model) { @@ -79,20 +108,20 @@ export default { <template> <div class="ci-table" role="grid"> <div class="gl-responsive-table-row table-row-header" role="row"> - <div class="table-section section-15 environments-name" role="columnheader"> - {{ s__('Environments|Environment') }} + <div class="table-section" :class="tableData.name.spacing" role="columnheader"> + {{ tableData.name.title }} </div> - <div class="table-section section-10 environments-deploy" role="columnheader"> - {{ s__('Environments|Deployment') }} + <div class="table-section" :class="tableData.deploy.spacing" role="columnheader"> + {{ tableData.deploy.title }} </div> - <div class="table-section section-15 environments-build" role="columnheader"> - {{ s__('Environments|Job') }} + <div class="table-section" :class="tableData.build.spacing" role="columnheader"> + {{ tableData.build.title }} </div> - <div class="table-section section-20 environments-commit" role="columnheader"> - {{ s__('Environments|Commit') }} + <div class="table-section" :class="tableData.commit.spacing" role="columnheader"> + {{ tableData.commit.title }} </div> - <div class="table-section section-10 environments-date" role="columnheader"> - {{ s__('Environments|Updated') }} + <div class="table-section" :class="tableData.date.spacing" role="columnheader"> + {{ tableData.date.title }} </div> </div> <template v-for="(model, i) in sortedEnvironments" :model="model"> @@ -101,6 +130,7 @@ export default { :key="`environment-item-${i}`" :model="model" :can-read-environment="canReadEnvironment" + :table-data="tableData" /> <div @@ -115,7 +145,8 @@ export default { :is-loading="model.isLoadingDeployBoard" :is-empty="model.isEmptyDeployBoard" :has-legacy-app-label="model.hasLegacyAppLabel" - :logs-path="model.logs_path" + :project-path="model.project_path" + :environment-name="model.name" /> </div> </div> @@ -132,6 +163,7 @@ export default { :key="`env-item-${i}-${index}`" :model="children" :can-read-environment="canReadEnvironment" + :table-data="tableData" /> <div :key="`sub-div-${i}`"> diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue index 6fd0561f682..d60c2efd618 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.vue +++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue @@ -40,13 +40,13 @@ export default { <div :class="cssContainerClass"> <stop-environment-modal :environment="environmentInStopModal" /> - <div v-if="!isLoading" class="top-area"> - <h4 class="js-folder-name environments-folder-name"> - {{ s__('Environments|Environments') }} / - <b>{{ folderName }}</b> - </h4> + <h4 class="js-folder-name environments-folder-name"> + {{ s__('Environments|Environments') }} / + <b>{{ folderName }}</b> + </h4> - <tabs :tabs="tabs" scope="environments" @onChangeTab="onChangeTab" /> + <div class="top-area"> + <tabs v-if="!isLoading" :tabs="tabs" scope="environments" @onChangeTab="onChangeTab" /> </div> <container diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js index dcdaf8731f8..9a68619d4f7 100644 --- a/app/assets/javascripts/environments/index.js +++ b/app/assets/javascripts/environments/index.js @@ -21,7 +21,6 @@ export default () => newEnvironmentPath: environmentsData.newEnvironmentPath, helpPagePath: environmentsData.helpPagePath, deployBoardsHelpPath: environmentsData.deployBoardsHelpPath, - cssContainerClass: environmentsData.cssClass, canCreateEnvironment: parseBoolean(environmentsData.canCreateEnvironment), canReadEnvironment: parseBoolean(environmentsData.canReadEnvironment), }; @@ -33,7 +32,6 @@ export default () => newEnvironmentPath: this.newEnvironmentPath, helpPagePath: this.helpPagePath, deployBoardsHelpPath: this.deployBoardsHelpPath, - cssContainerClass: this.cssContainerClass, canCreateEnvironment: this.canCreateEnvironment, canReadEnvironment: this.canReadEnvironment, ...this.canaryCalloutProps, diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js index 5fb420e9da5..81c257acd53 100644 --- a/app/assets/javascripts/environments/stores/environments_store.js +++ b/app/assets/javascripts/environments/stores/environments_store.js @@ -1,5 +1,5 @@ -import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { setDeployBoard } from 'ee_else_ce/environments/stores/helpers'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; /** * Environments Store. diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index 37c9818f869..14b2e59009a 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -1,8 +1,9 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import dateFormat from 'dateformat'; -import { __, sprintf } from '~/locale'; -import { GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import { GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import { __, sprintf, n__ } from '~/locale'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; import Icon from '~/vue_shared/components/icon.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import Stacktrace from './stacktrace.vue'; @@ -12,7 +13,8 @@ import { trackClickErrorLinkToSentryOptions } from '../utils'; export default { components: { - GlButton, + LoadingButton, + GlFormInput, GlLink, GlLoadingIcon, TooltipOnTruncate, @@ -32,6 +34,19 @@ export default { type: String, required: true, }, + projectIssuesPath: { + type: String, + required: true, + }, + csrfToken: { + type: String, + required: true, + }, + }, + data() { + return { + issueCreationInProgress: false, + }; }, computed: { ...mapState('details', ['error', 'loading', 'loadingStacktrace', 'stacktraceData']), @@ -41,7 +56,7 @@ export default { __('Reported %{timeAgo} by %{reportedBy}'), { reportedBy: `<strong>${this.error.culprit}</strong>`, - timeAgo: this.timeFormated(this.stacktraceData.date_received), + timeAgo: this.timeFormatted(this.stacktraceData.date_received), }, false, ); @@ -58,6 +73,27 @@ export default { showStacktrace() { return Boolean(!this.loadingStacktrace && this.stacktrace && this.stacktrace.length); }, + issueTitle() { + return this.error.title; + }, + issueDescription() { + return sprintf( + __( + '%{description}- Sentry event: %{errorUrl}- First seen: %{firstSeen}- Last seen: %{lastSeen} %{countLabel}: %{count}%{userCountLabel}: %{userCount}', + ), + { + description: '# Error Details:\n', + errorUrl: `${this.error.external_url}\n`, + firstSeen: `\n${this.error.first_seen}\n`, + lastSeen: `${this.error.last_seen}\n`, + countLabel: n__('- Event', '- Events', this.error.count), + count: `${this.error.count}\n`, + userCountLabel: n__('- User', '- Users', this.error.user_count), + userCount: `${this.error.user_count}\n`, + }, + false, + ); + }, }, mounted() { this.startPollingDetails(this.issueDetailsPath); @@ -66,8 +102,12 @@ export default { methods: { ...mapActions('details', ['startPollingDetails', 'startPollingStacktrace']), trackClickErrorLinkToSentryOptions, + createIssue() { + this.issueCreationInProgress = true; + this.$refs.sentryIssueForm.submit(); + }, formatDate(date) { - return `${this.timeFormated(date)} (${dateFormat(date, 'UTC:yyyy-mm-dd h:MM:ssTT Z')})`; + return `${this.timeFormatted(date)} (${dateFormat(date, 'UTC:yyyy-mm-dd h:MM:ssTT Z')})`; }, }, }; @@ -78,13 +118,27 @@ export default { <div v-if="loading" class="py-3"> <gl-loading-icon :size="3" /> </div> - <div v-else-if="showDetails" class="error-details"> <div class="top-area align-items-center justify-content-between py-3"> <span v-if="!loadingStacktrace && stacktrace" v-html="reported"></span> - <!-- <gl-button class="my-3 ml-auto" variant="success"> - {{ __('Create Issue') }} - </gl-button>--> + <form ref="sentryIssueForm" :action="projectIssuesPath" method="POST"> + <gl-form-input class="hidden" name="issue[title]" :value="issueTitle" /> + <input name="issue[description]" :value="issueDescription" type="hidden" /> + <gl-form-input + :value="error.id" + class="hidden" + name="issue[sentry_issue_attributes][sentry_issue_identifier]" + /> + <gl-form-input :value="csrfToken" class="hidden" name="authenticity_token" /> + <loading-button + v-if="!error.gitlab_issue" + class="btn-success" + :label="__('Create issue')" + :loading="issueCreationInProgress" + data-qa-selector="create_issue_button" + @click="createIssue" + /> + </form> </div> <div> <tooltip-on-truncate :title="error.title" truncate-target="child" placement="top"> @@ -92,6 +146,12 @@ export default { </tooltip-on-truncate> <h3>{{ __('Error details') }}</h3> <ul> + <li v-if="error.gitlab_issue"> + <span class="bold">{{ __('GitLab Issue') }}:</span> + <gl-link :href="error.gitlab_issue"> + <span>{{ error.gitlab_issue }}</span> + </gl-link> + </li> <li> <span class="bold">{{ __('Sentry event') }}:</span> <gl-link diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue index 88139ce7403..8e2128ac713 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -1,39 +1,57 @@ <script> -import { mapActions, mapState, mapGetters } from 'vuex'; +import { mapActions, mapState } from 'vuex'; import { GlEmptyState, GlButton, + GlIcon, GlLink, GlLoadingIcon, GlTable, - GlSearchBoxByType, + GlFormInput, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlTooltipDirective, + GlPagination, } from '@gitlab/ui'; -import { visitUrl } from '~/lib/utils/url_utility'; +import AccessorUtils from '~/lib/utils/accessor'; import Icon from '~/vue_shared/components/icon.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { __ } from '~/locale'; -import TrackEventDirective from '~/vue_shared/directives/track_event'; -import { trackViewInSentryOptions } from '../utils'; +import _ from 'underscore'; export default { + FIRST_PAGE: 1, + PREV_PAGE: 1, + NEXT_PAGE: 2, fields: [ { key: 'error', label: __('Open errors'), thClass: 'w-70p' }, { key: 'events', label: __('Events') }, { key: 'users', label: __('Users') }, { key: 'lastSeen', label: __('Last seen'), thClass: 'w-15p' }, ], + sortFields: { + last_seen: __('Last Seen'), + first_seen: __('First Seen'), + frequency: __('Frequency'), + }, components: { GlEmptyState, GlButton, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlIcon, GlLink, GlLoadingIcon, GlTable, - GlSearchBoxByType, + GlFormInput, Icon, + GlPagination, TimeAgo, }, directives: { - TrackEvent: TrackEventDirective, + GlTooltip: GlTooltipDirective, }, props: { indexPath: { @@ -57,112 +75,214 @@ export default { required: true, }, }, + hasLocalStorage: AccessorUtils.isLocalStorageAccessSafe(), data() { return { errorSearchQuery: '', + pageValue: this.$options.FIRST_PAGE, }; }, computed: { - ...mapState('list', ['errors', 'externalUrl', 'loading']), - ...mapGetters('list', ['filterErrorsByTitle']), - filteredErrors() { - return this.errorSearchQuery ? this.filterErrorsByTitle(this.errorSearchQuery) : this.errors; + ...mapState('list', [ + 'errors', + 'loading', + 'searchQuery', + 'sortField', + 'recentSearches', + 'pagination', + ]), + paginationRequired() { + return !_.isEmpty(this.pagination); + }, + }, + watch: { + pagination() { + if (typeof this.pagination.previous === 'undefined') { + this.pageValue = this.$options.FIRST_PAGE; + } }, }, created() { if (this.errorTrackingEnabled) { - this.startPolling(this.indexPath); + this.setEndpoint(this.indexPath); + this.startPolling(); } }, methods: { - ...mapActions('list', ['startPolling', 'restartPolling']), - trackViewInSentryOptions, - viewDetails(errorId) { - visitUrl(`error_tracking/${errorId}/details`); + ...mapActions('list', [ + 'startPolling', + 'restartPolling', + 'setEndpoint', + 'searchByQuery', + 'sortByField', + 'addRecentSearch', + 'clearRecentSearches', + 'loadRecentSearches', + 'setIndexPath', + ]), + setSearchText(text) { + this.errorSearchQuery = text; + this.searchByQuery(text); + }, + getDetailsLink(errorId) { + return `error_tracking/${errorId}/details`; + }, + goToNextPage() { + this.pageValue = this.$options.NEXT_PAGE; + this.startPolling(`${this.indexPath}?cursor=${this.pagination.next.cursor}`); + }, + goToPrevPage() { + this.startPolling(`${this.indexPath}?cursor=${this.pagination.previous.cursor}`); + }, + goToPage(page) { + window.scrollTo(0, 0); + return page === this.$options.PREV_PAGE ? this.goToPrevPage() : this.goToNextPage(); + }, + isCurrentSortField(field) { + return field === this.sortField; }, }, }; </script> <template> - <div> + <div class="error-list"> <div v-if="errorTrackingEnabled"> - <div v-if="loading" class="py-3"> - <gl-loading-icon :size="3" /> - </div> - <div v-else> - <div class="d-flex flex-row justify-content-around bg-secondary border"> - <gl-search-box-by-type - v-model="errorSearchQuery" - class="col-lg-10 m-3 p-0" - :placeholder="__('Search or filter results...')" - type="search" - autofocus - /> - <gl-button - v-track-event="trackViewInSentryOptions(externalUrl)" - class="m-3" - variant="primary" - :href="externalUrl" - target="_blank" + <div + class="d-flex flex-row justify-content-around align-items-center bg-secondary border mt-2" + > + <div class="filtered-search-box flex-grow-1 my-3 ml-3 mr-2"> + <gl-dropdown + :text="__('Recent searches')" + class="filtered-search-history-dropdown-wrapper d-none d-md-block" + toggle-class="filtered-search-history-dropdown-toggle-button" + :disabled="loading" > - {{ __('View in Sentry') }} - <icon name="external-link" class="flex-shrink-0" /> - </gl-button> + <div v-if="!$options.hasLocalStorage" class="px-3"> + {{ __('This feature requires local storage to be enabled') }} + </div> + <template v-else-if="recentSearches.length > 0"> + <gl-dropdown-item + v-for="searchQuery in recentSearches" + :key="searchQuery" + @click="setSearchText(searchQuery)" + >{{ searchQuery }}</gl-dropdown-item + > + <gl-dropdown-divider /> + <gl-dropdown-item ref="clearRecentSearches" @click="clearRecentSearches">{{ + __('Clear recent searches') + }}</gl-dropdown-item> + </template> + <div v-else class="px-3">{{ __("You don't have any recent searches") }}</div> + </gl-dropdown> + <div class="filtered-search-input-container flex-fill"> + <gl-form-input + v-model="errorSearchQuery" + class="pl-2 filtered-search" + :disabled="loading" + :placeholder="__('Search or filter results…')" + autofocus + @keyup.enter.native="searchByQuery(errorSearchQuery)" + /> + </div> + <div class="gl-search-box-by-type-right-icons"> + <gl-button + v-if="errorSearchQuery.length > 0" + v-gl-tooltip.hover + :title="__('Clear')" + class="clear-search text-secondary" + name="clear" + @click="errorSearchQuery = ''" + > + <gl-icon name="close" :size="12" /> + </gl-button> + </div> </div> - <gl-table - class="mt-3" - :items="filteredErrors" - :fields="$options.fields" - :show-empty="true" - fixed - stacked="sm" + <gl-dropdown + :text="$options.sortFields[sortField]" + left + :disabled="loading" + class="mr-3" + menu-class="sort-dropdown" > - <template slot="HEAD_events" slot-scope="data"> - <div class="text-md-right">{{ data.label }}</div> - </template> - <template slot="HEAD_users" slot-scope="data"> - <div class="text-md-right">{{ data.label }}</div> - </template> - <template slot="error" slot-scope="errors"> - <div class="d-flex flex-column"> - <gl-link - class="d-flex text-dark" - target="_blank" - @click="viewDetails(errors.item.id)" - > - <strong class="text-truncate">{{ errors.item.title.trim() }}</strong> - </gl-link> - <span class="text-secondary text-truncate"> - {{ errors.item.culprit }} - </span> - </div> - </template> + <gl-dropdown-item + v-for="(label, field) in $options.sortFields" + :key="field" + @click="sortByField(field)" + > + <span class="d-flex"> + <icon + class="flex-shrink-0 append-right-4" + :class="{ invisible: !isCurrentSortField(field) }" + name="mobile-issue-close" + /> + {{ label }} + </span> + </gl-dropdown-item> + </gl-dropdown> + </div> - <template slot="events" slot-scope="errors"> - <div class="text-md-right">{{ errors.item.count }}</div> - </template> + <div v-if="loading" class="py-3"> + <gl-loading-icon size="md" /> + </div> - <template slot="users" slot-scope="errors"> - <div class="text-md-right">{{ errors.item.userCount }}</div> - </template> + <gl-table + v-else + class="mt-3" + :items="errors" + :fields="$options.fields" + :show-empty="true" + fixed + stacked="sm" + > + <template slot="HEAD_events" slot-scope="data"> + <div class="text-md-right">{{ data.label }}</div> + </template> + <template slot="HEAD_users" slot-scope="data"> + <div class="text-md-right">{{ data.label }}</div> + </template> + <template slot="error" slot-scope="errors"> + <div class="d-flex flex-column"> + <gl-link class="d-flex text-dark" :href="getDetailsLink(errors.item.id)"> + <strong class="text-truncate">{{ errors.item.title.trim() }}</strong> + </gl-link> + <span class="text-secondary text-truncate"> + {{ errors.item.culprit }} + </span> + </div> + </template> + <template slot="events" slot-scope="errors"> + <div class="text-md-right">{{ errors.item.count }}</div> + </template> - <template slot="lastSeen" slot-scope="errors"> - <div class="d-flex align-items-center"> - <time-ago :time="errors.item.lastSeen" class="text-secondary" /> - </div> - </template> - <template slot="empty"> - <div ref="empty"> - {{ __('No errors to display.') }} - <gl-link class="js-try-again" @click="restartPolling"> - {{ __('Check again') }} - </gl-link> - </div> - </template> - </gl-table> - </div> + <template slot="users" slot-scope="errors"> + <div class="text-md-right">{{ errors.item.userCount }}</div> + </template> + + <template slot="lastSeen" slot-scope="errors"> + <div class="d-flex align-items-center"> + <time-ago :time="errors.item.lastSeen" class="text-secondary" /> + </div> + </template> + <template slot="empty"> + <div ref="empty"> + {{ __('No errors to display.') }} + <gl-link class="js-try-again" @click="restartPolling"> + {{ __('Check again') }} + </gl-link> + </div> + </template> + </gl-table> + <gl-pagination + v-show="!loading" + v-if="paginationRequired" + :prev-page="$options.PREV_PAGE" + :next-page="$options.NEXT_PAGE" + :value="pageValue" + align="center" + @input="goToPage" + /> </div> <div v-else-if="userCanEnableErrorTracking"> <gl-empty-state diff --git a/app/assets/javascripts/error_tracking/components/stacktrace.vue b/app/assets/javascripts/error_tracking/components/stacktrace.vue index 6b71967624f..f58d54f2933 100644 --- a/app/assets/javascripts/error_tracking/components/stacktrace.vue +++ b/app/assets/javascripts/error_tracking/components/stacktrace.vue @@ -27,6 +27,8 @@ export default { :lines="entry.context" :file-path="entry.filename" :error-line="entry.lineNo" + :error-fn="entry.function" + :error-column="entry.colNo" :expanded="isFirstEntry(index)" /> </div> diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue index ad542c579a9..62fd379aa4c 100644 --- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue +++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue @@ -1,5 +1,6 @@ <script> import { GlTooltip } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import Icon from '~/vue_shared/components/icon.vue'; @@ -22,9 +23,20 @@ export default { type: String, required: true, }, + errorFn: { + type: String, + required: false, + default: '', + }, errorLine: { type: Number, - required: true, + required: false, + default: 0, + }, + errorColumn: { + type: Number, + required: false, + default: 0, }, expanded: { type: Boolean, @@ -38,12 +50,23 @@ export default { }; }, computed: { - linesLength() { - return this.lines.length; + hasCode() { + return Boolean(this.lines.length); }, collapseIcon() { return this.isExpanded ? 'chevron-down' : 'chevron-right'; }, + noCodeFn() { + return this.errorFn ? sprintf(__('in %{errorFn} '), { errorFn: this.errorFn }) : ''; + }, + noCodeLine() { + return this.errorLine + ? sprintf(__('at line %{errorLine}%{errorColumn}'), { + errorLine: this.errorLine, + errorColumn: this.errorColumn ? `:${this.errorColumn}` : '', + }) + : ''; + }, }, methods: { isHighlighted(lineNum) { @@ -66,27 +89,31 @@ export default { <template> <div class="file-holder"> <div ref="header" class="file-title file-title-flex-parent"> - <div class="file-header-content "> - <div class="d-inline-block cursor-pointer" @click="toggle()"> + <div class="file-header-content d-flex align-content-center"> + <div v-if="hasCode" class="d-inline-block cursor-pointer" @click="toggle()"> <icon :name="collapseIcon" :size="16" aria-hidden="true" class="append-right-5" /> </div> - <div class="d-inline-block append-right-4"> - <file-icon - :file-name="filePath" - :size="18" - aria-hidden="true" - css-classes="append-right-5" - /> - <strong v-gl-tooltip :title="filePath" class="file-title-name" data-container="body"> - {{ filePath }} - </strong> - </div> - + <file-icon + :file-name="filePath" + :size="18" + aria-hidden="true" + css-classes="append-right-5" + /> + <strong + v-gl-tooltip + :title="filePath" + class="file-title-name d-inline-block overflow-hidden text-truncate" + :class="{ 'limited-width': !hasCode }" + data-container="body" + > + {{ filePath }} + </strong> <clipboard-button :title="__('Copy file path')" :text="filePath" - css-class="btn-default btn-transparent btn-clipboard" + css-class="btn-default btn-transparent btn-clipboard position-static" /> + <span v-if="!hasCode" class="text-tertiary">{{ noCodeFn }}{{ noCodeLine }}</span> </div> </div> diff --git a/app/assets/javascripts/error_tracking/details.js b/app/assets/javascripts/error_tracking/details.js index b9b51a6539f..872cb8868a2 100644 --- a/app/assets/javascripts/error_tracking/details.js +++ b/app/assets/javascripts/error_tracking/details.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import store from './store'; import ErrorDetails from './components/error_details.vue'; +import csrf from '~/lib/utils/csrf'; export default () => { // eslint-disable-next-line no-new @@ -12,12 +13,14 @@ export default () => { store, render(createElement) { const domEl = document.querySelector(this.$options.el); - const { issueDetailsPath, issueStackTracePath } = domEl.dataset; + const { issueDetailsPath, issueStackTracePath, projectIssuesPath } = domEl.dataset; return createElement('error-details', { props: { issueDetailsPath, issueStackTracePath, + projectIssuesPath, + csrfToken: csrf.token, }, }); }, diff --git a/app/assets/javascripts/error_tracking/services/index.js b/app/assets/javascripts/error_tracking/services/index.js index 68988296cc2..3b3f8311d67 100644 --- a/app/assets/javascripts/error_tracking/services/index.js +++ b/app/assets/javascripts/error_tracking/services/index.js @@ -1,7 +1,7 @@ import axios from '~/lib/utils/axios_utils'; export default { - getSentryData({ endpoint }) { - return axios.get(endpoint); + getSentryData({ endpoint, params }) { + return axios.get(endpoint, { params }); }, }; diff --git a/app/assets/javascripts/error_tracking/store/details/getters.js b/app/assets/javascripts/error_tracking/store/details/getters.js index 7d13439d721..a36c84dc28c 100644 --- a/app/assets/javascripts/error_tracking/store/details/getters.js +++ b/app/assets/javascripts/error_tracking/store/details/getters.js @@ -1,3 +1,6 @@ -export const stacktrace = state => state.stacktraceData.stack_trace_entries.reverse(); +export const stacktrace = state => + state.stacktraceData.stack_trace_entries + ? state.stacktraceData.stack_trace_entries.reverse() + : []; export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/index.js b/app/assets/javascripts/error_tracking/store/index.js index 941c752e96a..ad05eecef6c 100644 --- a/app/assets/javascripts/error_tracking/store/index.js +++ b/app/assets/javascripts/error_tracking/store/index.js @@ -4,7 +4,6 @@ import Vuex from 'vuex'; import * as listActions from './list/actions'; import listMutations from './list/mutations'; import listState from './list/state'; -import * as listGetters from './list/getters'; import * as detailsActions from './details/actions'; import detailsMutations from './details/mutations'; @@ -21,7 +20,6 @@ export const createStore = () => state: listState(), actions: listActions, mutations: listMutations, - getters: listGetters, }, details: { namespaced: true, diff --git a/app/assets/javascripts/error_tracking/store/list/actions.js b/app/assets/javascripts/error_tracking/store/list/actions.js index 18c6e5e9695..c9e882c4ed2 100644 --- a/app/assets/javascripts/error_tracking/store/list/actions.js +++ b/app/assets/javascripts/error_tracking/store/list/actions.js @@ -6,17 +6,25 @@ import { __, sprintf } from '~/locale'; let eTagPoll; -export function startPolling({ commit, dispatch }, endpoint) { +export function startPolling({ state, commit, dispatch }) { + commit(types.SET_LOADING, true); + eTagPoll = new Poll({ resource: Service, method: 'getSentryData', - data: { endpoint }, + data: { + endpoint: state.endpoint, + params: { + search_term: state.searchQuery, + sort: state.sortField, + }, + }, successCallback: ({ data }) => { if (!data) { return; } + commit(types.SET_PAGINATION, data.pagination); commit(types.SET_ERRORS, data.errors); - commit(types.SET_EXTERNAL_URL, data.external_url); commit(types.SET_LOADING, false); dispatch('stopPolling'); }, @@ -43,10 +51,43 @@ export const stopPolling = () => { export function restartPolling({ commit }) { commit(types.SET_ERRORS, []); - commit(types.SET_EXTERNAL_URL, ''); commit(types.SET_LOADING, true); if (eTagPoll) eTagPoll.restart(); } +export function setIndexPath({ commit }, path) { + commit(types.SET_INDEX_PATH, path); +} + +export function loadRecentSearches({ commit }) { + commit(types.LOAD_RECENT_SEARCHES); +} + +export function addRecentSearch({ commit }, searchQuery) { + commit(types.ADD_RECENT_SEARCH, searchQuery); +} + +export function clearRecentSearches({ commit }) { + commit(types.CLEAR_RECENT_SEARCHES); +} + +export const searchByQuery = ({ commit, dispatch }, query) => { + const searchQuery = query.trim(); + commit(types.SET_SEARCH_QUERY, searchQuery); + commit(types.ADD_RECENT_SEARCH, searchQuery); + dispatch('stopPolling'); + dispatch('startPolling'); +}; + +export const sortByField = ({ commit, dispatch }, field) => { + commit(types.SET_SORT_FIELD, field); + dispatch('stopPolling'); + dispatch('startPolling'); +}; + +export const setEndpoint = ({ commit }, endpoint) => { + commit(types.SET_ENDPOINT, endpoint); +}; + export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/list/getters.js b/app/assets/javascripts/error_tracking/store/list/getters.js deleted file mode 100644 index 1a2ec62f79f..00000000000 --- a/app/assets/javascripts/error_tracking/store/list/getters.js +++ /dev/null @@ -1,4 +0,0 @@ -export const filterErrorsByTitle = state => errorQuery => - state.errors.filter(error => error.title.match(new RegExp(`${errorQuery}`, 'i'))); - -export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/list/mutation_types.js b/app/assets/javascripts/error_tracking/store/list/mutation_types.js index f9d77a6b08e..301984a1ee0 100644 --- a/app/assets/javascripts/error_tracking/store/list/mutation_types.js +++ b/app/assets/javascripts/error_tracking/store/list/mutation_types.js @@ -1,3 +1,10 @@ export const SET_ERRORS = 'SET_ERRORS'; -export const SET_EXTERNAL_URL = 'SET_EXTERNAL_URL'; +export const SET_INDEX_PATH = 'SET_INDEX_PATH'; export const SET_LOADING = 'SET_LOADING'; +export const ADD_RECENT_SEARCH = 'ADD_RECENT_SEARCH'; +export const CLEAR_RECENT_SEARCHES = 'CLEAR_RECENT_SEARCHES'; +export const LOAD_RECENT_SEARCHES = 'LOAD_RECENT_SEARCHES'; +export const SET_PAGINATION = 'SET_PAGINATION'; +export const SET_ENDPOINT = 'SET_ENDPOINT'; +export const SET_SORT_FIELD = 'SET_SORT_FIELD'; +export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY'; diff --git a/app/assets/javascripts/error_tracking/store/list/mutations.js b/app/assets/javascripts/error_tracking/store/list/mutations.js index e4bd81db9c9..5648013bb89 100644 --- a/app/assets/javascripts/error_tracking/store/list/mutations.js +++ b/app/assets/javascripts/error_tracking/store/list/mutations.js @@ -1,14 +1,59 @@ import * as types from './mutation_types'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import AccessorUtils from '~/lib/utils/accessor'; export default { [types.SET_ERRORS](state, data) { state.errors = convertObjectPropsToCamelCase(data, { deep: true }); }, - [types.SET_EXTERNAL_URL](state, url) { - state.externalUrl = url; - }, [types.SET_LOADING](state, loading) { state.loading = loading; }, + [types.SET_INDEX_PATH](state, path) { + state.indexPath = path; + }, + [types.ADD_RECENT_SEARCH](state, searchTerm) { + if (searchTerm.length === 0) { + return; + } + // remove any existing item, then add it to the start of the list + const recentSearches = state.recentSearches.filter(s => s !== searchTerm); + recentSearches.unshift(searchTerm); + // only keep the last 5 + state.recentSearches = recentSearches.slice(0, 5); + + if (AccessorUtils.isLocalStorageAccessSafe()) { + localStorage.setItem( + `recent-searches${state.indexPath}`, + JSON.stringify(state.recentSearches), + ); + } + }, + [types.CLEAR_RECENT_SEARCHES](state) { + state.recentSearches = []; + if (AccessorUtils.isLocalStorageAccessSafe()) { + localStorage.removeItem(`recent-searches${state.indexPath}`); + } + }, + [types.LOAD_RECENT_SEARCHES](state) { + const recentSearches = localStorage.getItem(`recent-searches${state.indexPath}`) || []; + try { + state.recentSearches = JSON.parse(recentSearches); + } catch (e) { + state.recentSearches = []; + throw e; + } + }, + [types.SET_PAGINATION](state, pagination) { + state.pagination = pagination; + }, + [types.SET_SORT_FIELD](state, field) { + state.sortField = field; + }, + [types.SET_SEARCH_QUERY](state, query) { + state.searchQuery = query; + }, + [types.SET_ENDPOINT](state, endpoint) { + state.endpoint = endpoint; + }, }; diff --git a/app/assets/javascripts/error_tracking/store/list/state.js b/app/assets/javascripts/error_tracking/store/list/state.js index d371350ef0e..93dc1040fde 100644 --- a/app/assets/javascripts/error_tracking/store/list/state.js +++ b/app/assets/javascripts/error_tracking/store/list/state.js @@ -1,5 +1,10 @@ export default () => ({ errors: [], - externalUrl: '', loading: true, + endpoint: null, + sortField: 'last_seen', + searchQuery: null, + indexPath: '', + recentSearches: [], + pagination: {}, }); diff --git a/app/assets/javascripts/error_tracking/utils.js b/app/assets/javascripts/error_tracking/utils.js index b832b1371b1..3c382ccd1aa 100644 --- a/app/assets/javascripts/error_tracking/utils.js +++ b/app/assets/javascripts/error_tracking/utils.js @@ -1,15 +1,4 @@ -/* eslint-disable @gitlab/i18n/no-non-i18n-strings */ - -/** - * Tracks snowplow event when user clicks View in Sentry btn - * @param {String} externalUrl that will be send as a property for the event - */ -export const trackViewInSentryOptions = url => ({ - category: 'Error Tracking', - action: 'click_view_in_sentry', - label: 'External Url', - property: url, -}); +/* eslint-disable @gitlab/i18n/no-non-i18n-strings, import/prefer-default-export */ /** * Tracks snowplow event when User clicks on error link to Sentry 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 d86116aa315..9f77fe8cd59 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 @@ -32,12 +32,16 @@ export default { placeholder="https://mysentryserver.com" @input="updateApiHost" /> + <p class="form-text text-muted"> + {{ + s__( + "ErrorTracking|If you self-host Sentry, enter the full URL of your Sentry instance. If you're using Sentry's hosted solution, enter https://sentry.io", + ) + }} + </p> <!-- eslint-enable @gitlab/vue-i18n/no-bare-attribute-strings --> </div> </div> - <p class="form-text text-muted"> - {{ s__('ErrorTracking|Find your hostname in your Sentry account settings page') }} - </p> </div> <div class="form-group" :class="{ 'gl-show-field-errors': connectError }"> <label class="label-bold" for="error-tracking-token"> diff --git a/app/assets/javascripts/filtered_search/.eslintrc.yml b/app/assets/javascripts/filtered_search/.eslintrc.yml new file mode 100644 index 00000000000..9faca7152f6 --- /dev/null +++ b/app/assets/javascripts/filtered_search/.eslintrc.yml @@ -0,0 +1,3 @@ +rules: + # https://gitlab.com/gitlab-org/gitlab/issues/28716 + import/no-cycle: off diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 5c2d32f4e85..a4edc5fd52d 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -1,7 +1,7 @@ import _ from 'underscore'; +import recentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys'; import { getParameterByName, getUrlParamsArray } from '~/lib/utils/common_utils'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; -import recentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys'; import { visitUrl } from '../lib/utils/url_utility'; import Flash from '../flash'; import FilteredSearchContainer from './container'; diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js index ea58dbd3fa9..1343ccd6468 100644 --- a/app/assets/javascripts/filtered_search/visual_token_value.js +++ b/app/assets/javascripts/filtered_search/visual_token_value.js @@ -1,4 +1,5 @@ import _ from 'underscore'; +import { USER_TOKEN_TYPES } from 'ee_else_ce/filtered_search/constants'; import FilteredSearchContainer from '~/filtered_search/container'; import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens'; import AjaxCache from '~/lib/utils/ajax_cache'; @@ -6,7 +7,6 @@ import DropdownUtils from '~/filtered_search/dropdown_utils'; import Flash from '~/flash'; import UsersCache from '~/lib/utils/users_cache'; import { __ } from '~/locale'; -import { USER_TOKEN_TYPES } from 'ee_else_ce/filtered_search/constants'; export default class VisualTokenValue { constructor(tokenValue, tokenType) { diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue index 968e255e1fc..8cf939254c1 100644 --- a/app/assets/javascripts/frequent_items/components/app.vue +++ b/app/assets/javascripts/frequent_items/components/app.vue @@ -1,7 +1,7 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; -import AccessorUtilities from '~/lib/utils/accessor'; import { GlLoadingIcon } from '@gitlab/ui'; +import AccessorUtilities from '~/lib/utils/accessor'; import eventHub from '../event_hub'; import store from '../store/'; import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants'; diff --git a/app/assets/javascripts/frequent_items/store/mutations.js b/app/assets/javascripts/frequent_items/store/mutations.js index 92ac3a2c94d..78ccef7f253 100644 --- a/app/assets/javascripts/frequent_items/store/mutations.js +++ b/app/assets/javascripts/frequent_items/store/mutations.js @@ -48,7 +48,7 @@ export default { }); }, [types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, results) { - const rawItems = results.data; + const rawItems = results.data ? results.data : results; // Api.groups returns array, Api.projects returns object Object.assign(state, { items: rawItems.map(rawItem => ({ id: rawItem.id, diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index db3ad0bb4c9..e25c9d90f60 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -3,11 +3,44 @@ import 'at.js'; import _ from 'underscore'; import glRegexp from './lib/utils/regexp'; import AjaxCache from './lib/utils/ajax_cache'; +import { spriteIcon } from './lib/utils/common_utils'; function sanitize(str) { return str.replace(/<(?:.|\n)*?>/gm, ''); } +export function membersBeforeSave(members) { + return _.map(members, member => { + const GROUP_TYPE = 'Group'; + + let title = ''; + if (member.username == null) { + return member; + } + title = member.name; + if (member.count && !member.mentionsDisabled) { + title += ` (${member.count})`; + } + + const autoCompleteAvatar = member.avatar_url || member.username.charAt(0).toUpperCase(); + + const rectAvatarClass = member.type === GROUP_TYPE ? 'rect-avatar' : ''; + const imgAvatar = `<img src="${member.avatar_url}" alt="${member.username}" class="avatar ${rectAvatarClass} avatar-inline center s26"/>`; + const txtAvatar = `<div class="avatar ${rectAvatarClass} center avatar-inline s26">${autoCompleteAvatar}</div>`; + const avatarIcon = member.mentionsDisabled + ? spriteIcon('notifications-off', 's16 vertical-align-middle prepend-left-5') + : ''; + + return { + username: member.username, + avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar, + title: sanitize(title), + search: sanitize(`${member.username} ${member.name}`), + icon: avatarIcon, + }; + }); +} + export const defaultAutocompleteConfig = { emojis: true, members: true, @@ -167,12 +200,13 @@ class GfmAutoComplete { alias: 'users', displayTpl(value) { let tmpl = GfmAutoComplete.Loading.template; - const { avatarTag, username, title } = value; + const { avatarTag, username, title, icon } = value; if (username != null) { tmpl = GfmAutoComplete.Members.templateFunction({ avatarTag, username, title, + icon, }); } return tmpl; @@ -185,33 +219,7 @@ class GfmAutoComplete { data: GfmAutoComplete.defaultLoadingData, callbacks: { ...this.getDefaultCallbacks(), - beforeSave(members) { - return $.map(members, m => { - let title = ''; - if (m.username == null) { - return m; - } - title = m.name; - if (m.count) { - title += ` (${m.count})`; - } - - const GROUP_TYPE = 'Group'; - - const autoCompleteAvatar = m.avatar_url || m.username.charAt(0).toUpperCase(); - - const rectAvatarClass = m.type === GROUP_TYPE ? 'rect-avatar' : ''; - const imgAvatar = `<img src="${m.avatar_url}" alt="${m.username}" class="avatar ${rectAvatarClass} avatar-inline center s26"/>`; - const txtAvatar = `<div class="avatar ${rectAvatarClass} center avatar-inline s26">${autoCompleteAvatar}</div>`; - - return { - username: m.username, - avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar, - title: sanitize(title), - search: sanitize(`${m.username} ${m.name}`), - }; - }); - }, + beforeSave: membersBeforeSave, }, }); } @@ -624,8 +632,8 @@ GfmAutoComplete.Emoji = { }; // Team Members GfmAutoComplete.Members = { - templateFunction({ avatarTag, username, title }) { - return `<li>${avatarTag} ${username} <small>${_.escape(title)}</small></li>`; + templateFunction({ avatarTag, username, title, icon }) { + return `<li>${avatarTag} ${username} <small>${_.escape(title)}</small> ${icon}</li>`; }, }; GfmAutoComplete.Labels = { diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 045f77af7ea..65d05887453 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-underscore-dangle, no-var, one-var, vars-on-top, no-shadow, no-cond-assign, no-return-assign, no-else-return, camelcase, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, no-param-reassign, no-loop-func */ +/* eslint-disable func-names, no-underscore-dangle, one-var, no-cond-assign, no-return-assign, no-else-return, camelcase, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, no-param-reassign, no-loop-func */ import $ from 'jquery'; import _ from 'underscore'; @@ -8,984 +8,887 @@ import { visitUrl } from './lib/utils/url_utility'; import { isObject } from './lib/utils/type_utility'; import renderItem from './gl_dropdown/render'; -var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote, GitLabDropdownInput; - -GitLabDropdownInput = (function() { - function GitLabDropdownInput(input, options) { - var $inputContainer, $clearButton; - var _this = this; - this.input = input; - this.options = options; - this.fieldName = this.options.fieldName || 'field-name'; - $inputContainer = this.input.parent(); - $clearButton = $inputContainer.find('.js-dropdown-input-clear'); - $clearButton.on( - 'click', - (function(_this) { - // Clear click - return function(e) { - e.preventDefault(); - e.stopPropagation(); - return _this.input - .val('') - .trigger('input') - .focus(); - }; - })(this), - ); +const BLUR_KEYCODES = [27, 40]; - this.input - .on('keydown', e => { - var keyCode = e.which; - if (keyCode === 13 && !options.elIsInput) { - e.preventDefault(); - } - }) - .on('input', e => { - var val = e.currentTarget.value || _this.options.inputFieldName; - val = val - .split(' ') - .join('-') // replaces space with dash - .replace(/[^a-zA-Z0-9 -]/g, '') - .toLowerCase() // replace non alphanumeric - .replace(/(-)\1+/g, '-'); // replace repeated dashes - _this.cb(_this.options.fieldName, val, {}, true); - _this.input - .closest('.dropdown') - .find('.dropdown-toggle-text') - .text(val); - }); - } +const HAS_VALUE_CLASS = 'has-value'; - GitLabDropdownInput.prototype.onInput = function(cb) { - this.cb = cb; - }; - - return GitLabDropdownInput; -})(); - -GitLabDropdownFilter = (function() { - var BLUR_KEYCODES, HAS_VALUE_CLASS; - - BLUR_KEYCODES = [27, 40]; - - HAS_VALUE_CLASS = 'has-value'; - - function GitLabDropdownFilter(input, options) { - var $clearButton, $inputContainer, ref, timeout; - this.input = input; - this.options = options; - this.filterInputBlur = (ref = this.options.filterInputBlur) != null ? ref : true; - $inputContainer = this.input.parent(); - $clearButton = $inputContainer.find('.js-dropdown-input-clear'); - $clearButton.on( - 'click', - (function(_this) { - // Clear click - return function(e) { - e.preventDefault(); - e.stopPropagation(); - return _this.input - .val('') - .trigger('input') - .focus(); - }; - })(this), - ); - // Key events - timeout = ''; - this.input - .on('keydown', e => { - var keyCode = e.which; - if (keyCode === 13 && !options.elIsInput) { - e.preventDefault(); - } - }) - .on('input', () => { - if (this.input.val() !== '' && !$inputContainer.hasClass(HAS_VALUE_CLASS)) { - $inputContainer.addClass(HAS_VALUE_CLASS); - } else if (this.input.val() === '' && $inputContainer.hasClass(HAS_VALUE_CLASS)) { - $inputContainer.removeClass(HAS_VALUE_CLASS); - } - // Only filter asynchronously only if option remote is set - if (this.options.remote) { - clearTimeout(timeout); - return (timeout = setTimeout(() => { - $inputContainer.parent().addClass('is-loading'); - - return this.options.query(this.input.val(), data => { - $inputContainer.parent().removeClass('is-loading'); - return this.options.callback(data); - }); - }, 250)); - } else { - return this.filter(this.input.val()); - } - }); - } +const LOADING_CLASS = 'is-loading'; - GitLabDropdownFilter.prototype.shouldBlur = function(keyCode) { - return BLUR_KEYCODES.indexOf(keyCode) !== -1; - }; +const PAGE_TWO_CLASS = 'is-page-two'; - GitLabDropdownFilter.prototype.filter = function(search_text) { - var data, elements, group, key, results, tmp; - if (this.options.onFilter) { - this.options.onFilter(search_text); - } - data = this.options.data(); - if (data != null && !this.options.filterByText) { - results = data; - if (search_text !== '') { - // When data is an array of objects therefore [object Array] e.g. - // [ - // { prop: 'foo' }, - // { prop: 'baz' } - // ] - if (_.isArray(data)) { - results = fuzzaldrinPlus.filter(data, search_text, { - key: this.options.keys, +const ACTIVE_CLASS = 'is-active'; + +const INDETERMINATE_CLASS = 'is-indeterminate'; + +let currentIndex = -1; + +const NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-item'; + +const SELECTABLE_CLASSES = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES}, .option-hidden)`; + +const CURSOR_SELECT_SCROLL_PADDING = 5; + +const FILTER_INPUT = '.dropdown-input .dropdown-input-field:not(.dropdown-no-filter)'; + +const NO_FILTER_INPUT = '.dropdown-input .dropdown-input-field.dropdown-no-filter'; + +function GitLabDropdownInput(input, options) { + const _this = this; + this.input = input; + this.options = options; + this.fieldName = this.options.fieldName || 'field-name'; + const $inputContainer = this.input.parent(); + const $clearButton = $inputContainer.find('.js-dropdown-input-clear'); + $clearButton.on('click', e => { + // Clear click + e.preventDefault(); + e.stopPropagation(); + return this.input + .val('') + .trigger('input') + .focus(); + }); + + this.input + .on('keydown', e => { + const keyCode = e.which; + if (keyCode === 13 && !options.elIsInput) { + e.preventDefault(); + } + }) + .on('input', e => { + let val = e.currentTarget.value || _this.options.inputFieldName; + val = val + .split(' ') + .join('-') // replaces space with dash + .replace(/[^a-zA-Z0-9 -]/g, '') + .toLowerCase() // replace non alphanumeric + .replace(/(-)\1+/g, '-'); // replace repeated dashes + _this.cb(_this.options.fieldName, val, {}, true); + _this.input + .closest('.dropdown') + .find('.dropdown-toggle-text') + .text(val); + }); +} + +GitLabDropdownInput.prototype.onInput = function(cb) { + this.cb = cb; +}; + +function GitLabDropdownFilter(input, options) { + let ref, timeout; + this.input = input; + this.options = options; + this.filterInputBlur = (ref = this.options.filterInputBlur) != null ? ref : true; + const $inputContainer = this.input.parent(); + const $clearButton = $inputContainer.find('.js-dropdown-input-clear'); + $clearButton.on('click', e => { + // Clear click + e.preventDefault(); + e.stopPropagation(); + return this.input + .val('') + .trigger('input') + .focus(); + }); + // Key events + timeout = ''; + this.input + .on('keydown', e => { + const keyCode = e.which; + if (keyCode === 13 && !options.elIsInput) { + e.preventDefault(); + } + }) + .on('input', () => { + if (this.input.val() !== '' && !$inputContainer.hasClass(HAS_VALUE_CLASS)) { + $inputContainer.addClass(HAS_VALUE_CLASS); + } else if (this.input.val() === '' && $inputContainer.hasClass(HAS_VALUE_CLASS)) { + $inputContainer.removeClass(HAS_VALUE_CLASS); + } + // Only filter asynchronously only if option remote is set + if (this.options.remote) { + clearTimeout(timeout); + return (timeout = setTimeout(() => { + $inputContainer.parent().addClass('is-loading'); + + return this.options.query(this.input.val(), data => { + $inputContainer.parent().removeClass('is-loading'); + return this.options.callback(data); }); - } else { - // If data is grouped therefore an [object Object]. e.g. - // { - // groupName1: [ - // { prop: 'foo' }, - // { prop: 'baz' } - // ], - // groupName2: [ - // { prop: 'abc' }, - // { prop: 'def' } - // ] - // } - if (isObject(data)) { - results = {}; - for (key in data) { - group = data[key]; - tmp = fuzzaldrinPlus.filter(group, search_text, { - key: this.options.keys, - }); - if (tmp.length) { - results[key] = tmp.map(item => item); - } + }, 250)); + } else { + return this.filter(this.input.val()); + } + }); +} + +GitLabDropdownFilter.prototype.shouldBlur = function(keyCode) { + return BLUR_KEYCODES.indexOf(keyCode) !== -1; +}; + +GitLabDropdownFilter.prototype.filter = function(search_text) { + let elements, group, key, results, tmp; + if (this.options.onFilter) { + this.options.onFilter(search_text); + } + const data = this.options.data(); + if (data != null && !this.options.filterByText) { + results = data; + if (search_text !== '') { + // When data is an array of objects therefore [object Array] e.g. + // [ + // { prop: 'foo' }, + // { prop: 'baz' } + // ] + if (_.isArray(data)) { + results = fuzzaldrinPlus.filter(data, search_text, { + key: this.options.keys, + }); + } else { + // If data is grouped therefore an [object Object]. e.g. + // { + // groupName1: [ + // { prop: 'foo' }, + // { prop: 'baz' } + // ], + // groupName2: [ + // { prop: 'abc' }, + // { prop: 'def' } + // ] + // } + if (isObject(data)) { + results = {}; + for (key in data) { + group = data[key]; + tmp = fuzzaldrinPlus.filter(group, search_text, { + key: this.options.keys, + }); + if (tmp.length) { + results[key] = tmp.map(item => item); } } } } - return this.options.callback(results); - } else { - elements = this.options.elements(); - if (search_text) { - elements.each(function() { - var $el, matches; - $el = $(this); - matches = fuzzaldrinPlus.match($el.text().trim(), search_text); - if (!$el.is('.dropdown-header')) { - if (matches.length) { - return $el.show().removeClass('option-hidden'); - } else { - return $el.hide().addClass('option-hidden'); - } + } + return this.options.callback(results); + } else { + elements = this.options.elements(); + if (search_text) { + elements.each(function() { + const $el = $(this); + const matches = fuzzaldrinPlus.match($el.text().trim(), search_text); + if (!$el.is('.dropdown-header')) { + if (matches.length) { + return $el.show().removeClass('option-hidden'); + } else { + return $el.hide().addClass('option-hidden'); } - }); - } else { - elements.show().removeClass('option-hidden'); - } - - elements - .parent() - .find('.dropdown-menu-empty-item') - .toggleClass('hidden', elements.is(':visible')); + } + }); + } else { + elements.show().removeClass('option-hidden'); } - }; - - return GitLabDropdownFilter; -})(); -GitLabDropdownRemote = (function() { - function GitLabDropdownRemote(dataEndpoint, options) { - this.dataEndpoint = dataEndpoint; - this.options = options; + elements + .parent() + .find('.dropdown-menu-empty-item') + .toggleClass('hidden', elements.is(':visible')); } +}; - GitLabDropdownRemote.prototype.execute = function() { - if (typeof this.dataEndpoint === 'string') { - return this.fetchData(); - } else if (typeof this.dataEndpoint === 'function') { - if (this.options.beforeSend) { - this.options.beforeSend(); - } - return this.dataEndpoint( - '', - (function(_this) { - // Fetch the data by calling the data funcfion - return function(data) { - if (_this.options.success) { - _this.options.success(data); - } - if (_this.options.beforeSend) { - return _this.options.beforeSend(); - } - }; - })(this), - ); - } - }; +function GitLabDropdownRemote(dataEndpoint, options) { + this.dataEndpoint = dataEndpoint; + this.options = options; +} - GitLabDropdownRemote.prototype.fetchData = function() { +GitLabDropdownRemote.prototype.execute = function() { + if (typeof this.dataEndpoint === 'string') { + return this.fetchData(); + } else if (typeof this.dataEndpoint === 'function') { if (this.options.beforeSend) { this.options.beforeSend(); } - - // Fetch the data through ajax if the data is a string - return axios.get(this.dataEndpoint).then(({ data }) => { + return this.dataEndpoint('', data => { + // Fetch the data by calling the data function if (this.options.success) { - return this.options.success(data); + this.options.success(data); + } + if (this.options.beforeSend) { + return this.options.beforeSend(); } }); - }; - - return GitLabDropdownRemote; -})(); - -GitLabDropdown = (function() { - var ACTIVE_CLASS, - FILTER_INPUT, - NO_FILTER_INPUT, - INDETERMINATE_CLASS, - LOADING_CLASS, - PAGE_TWO_CLASS, - NON_SELECTABLE_CLASSES, - SELECTABLE_CLASSES, - CURSOR_SELECT_SCROLL_PADDING, - currentIndex; - - LOADING_CLASS = 'is-loading'; - - PAGE_TWO_CLASS = 'is-page-two'; - - ACTIVE_CLASS = 'is-active'; - - INDETERMINATE_CLASS = 'is-indeterminate'; + } +}; - currentIndex = -1; +GitLabDropdownRemote.prototype.fetchData = function() { + if (this.options.beforeSend) { + this.options.beforeSend(); + } - NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-item'; - - SELECTABLE_CLASSES = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES}, .option-hidden)`; - - CURSOR_SELECT_SCROLL_PADDING = 5; - - FILTER_INPUT = '.dropdown-input .dropdown-input-field:not(.dropdown-no-filter)'; - - NO_FILTER_INPUT = '.dropdown-input .dropdown-input-field.dropdown-no-filter'; - - function GitLabDropdown(el1, options) { - var searchFields, selector, self; - this.el = el1; - this.options = options; - this.updateLabel = this.updateLabel.bind(this); - this.hidden = this.hidden.bind(this); - this.opened = this.opened.bind(this); - this.shouldPropagate = this.shouldPropagate.bind(this); - self = this; - selector = $(this.el).data('target'); - this.dropdown = selector != null ? $(selector) : $(this.el).parent(); - // Set Defaults - this.filterInput = this.options.filterInput || this.getElement(FILTER_INPUT); - this.noFilterInput = this.options.noFilterInput || this.getElement(NO_FILTER_INPUT); - this.highlight = Boolean(this.options.highlight); - this.icon = Boolean(this.options.icon); - this.filterInputBlur = - this.options.filterInputBlur != null ? this.options.filterInputBlur : true; - // If no input is passed create a default one - self = this; - // If selector was passed - if (_.isString(this.filterInput)) { - this.filterInput = this.getElement(this.filterInput); + // Fetch the data through ajax if the data is a string + return axios.get(this.dataEndpoint).then(({ data }) => { + if (this.options.success) { + return this.options.success(data); } - searchFields = this.options.search ? this.options.search.fields : []; - if (this.options.data) { - // If we provided data - // data could be an array of objects or a group of arrays - if (_.isObject(this.options.data) && !_.isFunction(this.options.data)) { - this.fullData = this.options.data; - currentIndex = -1; - this.parseData(this.options.data); - this.focusTextInput(); - } else { - this.remote = new GitLabDropdownRemote(this.options.data, { - dataType: this.options.dataType, - beforeSend: this.toggleLoading.bind(this), - success: (function(_this) { - return function(data) { - _this.fullData = data; - _this.parseData(_this.fullData); - _this.focusTextInput(); - - // Update dropdown position since remote data may have changed dropdown size - _this.dropdown.find('.dropdown-menu-toggle').dropdown('update'); - - if ( - _this.options.filterable && - _this.filter && - _this.filter.input && - _this.filter.input.val() && - _this.filter.input.val().trim() !== '' - ) { - return _this.filter.input.trigger('input'); - } - }; - // Remote data - })(this), - instance: this, - }); - } - } - if (this.noFilterInput.length) { - this.plainInput = new GitLabDropdownInput(this.noFilterInput, this.options); - this.plainInput.onInput(this.addInput.bind(this)); - } - // Init filterable - if (this.options.filterable) { - this.filter = new GitLabDropdownFilter(this.filterInput, { - elIsInput: $(this.el).is('input'), - filterInputBlur: this.filterInputBlur, - filterByText: this.options.filterByText, - onFilter: this.options.onFilter, - remote: this.options.filterRemote, - query: this.options.data, - keys: searchFields, + }); +}; + +function GitLabDropdown(el1, options) { + let selector, self; + this.el = el1; + this.options = options; + this.updateLabel = this.updateLabel.bind(this); + this.hidden = this.hidden.bind(this); + this.opened = this.opened.bind(this); + this.shouldPropagate = this.shouldPropagate.bind(this); + self = this; + selector = $(this.el).data('target'); + this.dropdown = selector != null ? $(selector) : $(this.el).parent(); + // Set Defaults + this.filterInput = this.options.filterInput || this.getElement(FILTER_INPUT); + this.noFilterInput = this.options.noFilterInput || this.getElement(NO_FILTER_INPUT); + this.highlight = Boolean(this.options.highlight); + this.icon = Boolean(this.options.icon); + this.filterInputBlur = this.options.filterInputBlur != null ? this.options.filterInputBlur : true; + // If no input is passed create a default one + self = this; + // If selector was passed + if (_.isString(this.filterInput)) { + this.filterInput = this.getElement(this.filterInput); + } + const searchFields = this.options.search ? this.options.search.fields : []; + if (this.options.data) { + // If we provided data + // data could be an array of objects or a group of arrays + if (_.isObject(this.options.data) && !_.isFunction(this.options.data)) { + this.fullData = this.options.data; + currentIndex = -1; + this.parseData(this.options.data); + this.focusTextInput(); + } else { + this.remote = new GitLabDropdownRemote(this.options.data, { + dataType: this.options.dataType, + beforeSend: this.toggleLoading.bind(this), + success: data => { + this.fullData = data; + this.parseData(this.fullData); + this.focusTextInput(); + + // Update dropdown position since remote data may have changed dropdown size + this.dropdown.find('.dropdown-menu-toggle').dropdown('update'); + + if ( + this.options.filterable && + this.filter && + this.filter.input && + this.filter.input.val() && + this.filter.input.val().trim() !== '' + ) { + return this.filter.input.trigger('input'); + } + }, instance: this, - elements: (function(_this) { - return function() { - selector = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES})`; - if (_this.dropdown.find('.dropdown-toggle-page').length) { - selector = `.dropdown-page-one ${selector}`; - } - return $(selector, this.instance.dropdown); - }; - })(this), - data: (function(_this) { - return function() { - return _this.fullData; - }; - })(this), - callback: (function(_this) { - return function(data) { - _this.parseData(data); - if (_this.filterInput.val() !== '') { - selector = SELECTABLE_CLASSES; - if (_this.dropdown.find('.dropdown-toggle-page').length) { - selector = `.dropdown-page-one ${selector}`; - } - if ($(_this.el).is('input')) { - currentIndex = -1; - } else { - $(selector, _this.dropdown) - .first() - .find('a') - .addClass('is-focused'); - currentIndex = 0; - } - } - }; - })(this), }); } - // Event listeners - this.dropdown.on('shown.bs.dropdown', this.opened); - this.dropdown.on('hidden.bs.dropdown', this.hidden); - $(this.el).on('update.label', this.updateLabel); - this.dropdown.on('click', '.dropdown-menu, .dropdown-menu-close', this.shouldPropagate); - this.dropdown.on( - 'keyup', - (function(_this) { - return function(e) { - // Escape key - if (e.which === 27) { - return $('.dropdown-menu-close', _this.dropdown).trigger('click'); + } + if (this.noFilterInput.length) { + this.plainInput = new GitLabDropdownInput(this.noFilterInput, this.options); + this.plainInput.onInput(this.addInput.bind(this)); + } + // Init filterable + if (this.options.filterable) { + this.filter = new GitLabDropdownFilter(this.filterInput, { + elIsInput: $(this.el).is('input'), + filterInputBlur: this.filterInputBlur, + filterByText: this.options.filterByText, + onFilter: this.options.onFilter, + remote: this.options.filterRemote, + query: this.options.data, + keys: searchFields, + instance: this, + elements: () => { + selector = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES})`; + if (this.dropdown.find('.dropdown-toggle-page').length) { + selector = `.dropdown-page-one ${selector}`; + } + return $(selector, this.dropdown); + }, + data: () => this.fullData, + callback: data => { + this.parseData(data); + if (this.filterInput.val() !== '') { + selector = SELECTABLE_CLASSES; + if (this.dropdown.find('.dropdown-toggle-page').length) { + selector = `.dropdown-page-one ${selector}`; } - }; - })(this), - ); - this.dropdown.on( - 'blur', - 'a', - (function(_this) { - return function(e) { - var $dropdownMenu, $relatedTarget; - if (e.relatedTarget != null) { - $relatedTarget = $(e.relatedTarget); - $dropdownMenu = $relatedTarget.closest('.dropdown-menu'); - if ($dropdownMenu.length === 0) { - return _this.dropdown.removeClass('show'); - } + if ($(this.el).is('input')) { + currentIndex = -1; + } else { + $(selector, this.dropdown) + .first() + .find('a') + .addClass('is-focused'); + currentIndex = 0; } - }; - })(this), - ); - if (this.dropdown.find('.dropdown-toggle-page').length) { - this.dropdown.find('.dropdown-toggle-page, .dropdown-menu-back').on( - 'click', - (function(_this) { - return function(e) { - e.preventDefault(); - e.stopPropagation(); - return _this.togglePage(); - }; - })(this), - ); + } + }, + }); + } + // Event listeners + this.dropdown.on('shown.bs.dropdown', this.opened); + this.dropdown.on('hidden.bs.dropdown', this.hidden); + $(this.el).on('update.label', this.updateLabel); + this.dropdown.on('click', '.dropdown-menu, .dropdown-menu-close', this.shouldPropagate); + this.dropdown.on('keyup', e => { + // Escape key + if (e.which === 27) { + return $('.dropdown-menu-close', this.dropdown).trigger('click'); } - if (this.options.selectable) { - selector = '.dropdown-content a'; - if (this.dropdown.find('.dropdown-toggle-page').length) { - selector = '.dropdown-page-one .dropdown-content a'; + }); + this.dropdown.on('blur', 'a', e => { + let $dropdownMenu, $relatedTarget; + if (e.relatedTarget != null) { + $relatedTarget = $(e.relatedTarget); + $dropdownMenu = $relatedTarget.closest('.dropdown-menu'); + if ($dropdownMenu.length === 0) { + return this.dropdown.removeClass('show'); + } + } + }); + if (this.dropdown.find('.dropdown-toggle-page').length) { + this.dropdown.find('.dropdown-toggle-page, .dropdown-menu-back').on('click', e => { + e.preventDefault(); + e.stopPropagation(); + return this.togglePage(); + }); + } + if (this.options.selectable) { + selector = '.dropdown-content a'; + if (this.dropdown.find('.dropdown-toggle-page').length) { + selector = '.dropdown-page-one .dropdown-content a'; + } + this.dropdown.on('click', selector, e => { + const $el = $(e.currentTarget); + const selected = self.rowClicked($el); + const selectedObj = selected ? selected[0] : null; + const isMarking = selected ? selected[1] : null; + if (this.options.clicked) { + this.options.clicked.call(this, { + selectedObj, + $el, + e, + isMarking, + }); } - this.dropdown.on('click', selector, e => { - var $el, selected, selectedObj, isMarking; - $el = $(e.currentTarget); - selected = self.rowClicked($el); - selectedObj = selected ? selected[0] : null; - isMarking = selected ? selected[1] : null; - if (this.options.clicked) { - this.options.clicked.call(this, { - selectedObj, - $el, - e, - isMarking, - }); - } - // Update label right after all modifications in dropdown has been done - if (this.options.toggleLabel) { - this.updateLabel(selectedObj, $el, this); - } + // Update label right after all modifications in dropdown has been done + if (this.options.toggleLabel) { + this.updateLabel(selectedObj, $el, this); + } - $el.trigger('blur'); - }); - } + $el.trigger('blur'); + }); } +} - // Finds an element inside wrapper element - GitLabDropdown.prototype.getElement = function(selector) { - return this.dropdown.find(selector); - }; +// Finds an element inside wrapper element +GitLabDropdown.prototype.getElement = function(selector) { + return this.dropdown.find(selector); +}; - GitLabDropdown.prototype.toggleLoading = function() { - return $('.dropdown-menu', this.dropdown).toggleClass(LOADING_CLASS); - }; +GitLabDropdown.prototype.toggleLoading = function() { + return $('.dropdown-menu', this.dropdown).toggleClass(LOADING_CLASS); +}; - GitLabDropdown.prototype.togglePage = function() { - var menu; - menu = $('.dropdown-menu', this.dropdown); - if (menu.hasClass(PAGE_TWO_CLASS)) { - if (this.remote) { - this.remote.execute(); - } +GitLabDropdown.prototype.togglePage = function() { + const menu = $('.dropdown-menu', this.dropdown); + if (menu.hasClass(PAGE_TWO_CLASS)) { + if (this.remote) { + this.remote.execute(); } - menu.toggleClass(PAGE_TWO_CLASS); - // Focus first visible input on active page - return this.dropdown.find('[class^="dropdown-page-"]:visible :text:visible:first').focus(); - }; - - GitLabDropdown.prototype.parseData = function(data) { - var full_html, groupData, html, name; - this.renderedData = data; - if (this.options.filterable && data.length === 0) { - // render no matching results - html = [this.noResults()]; - } else { - // Handle array groups - if (isObject(data)) { - html = []; - for (name in data) { - groupData = data[name]; - html.push( - this.renderItem( - { - content: name, - type: 'header', - }, - name, - ), - ); - this.renderData(groupData, name).map(item => html.push(item)); - } - } else { - // Render each row - html = this.renderData(data); + } + menu.toggleClass(PAGE_TWO_CLASS); + // Focus first visible input on active page + return this.dropdown.find('[class^="dropdown-page-"]:visible :text:visible:first').focus(); +}; + +GitLabDropdown.prototype.parseData = function(data) { + let groupData, html, name; + this.renderedData = data; + if (this.options.filterable && data.length === 0) { + // render no matching results + html = [this.noResults()]; + } else { + // Handle array groups + if (isObject(data)) { + html = []; + for (name in data) { + groupData = data[name]; + html.push( + this.renderItem( + { + content: name, + type: 'header', + }, + name, + ), + ); + this.renderData(groupData, name).map(item => html.push(item)); } + } else { + // Render each row + html = this.renderData(data); } - // Render the full menu - full_html = this.renderMenu(html); - return this.appendMenu(full_html); - }; - - GitLabDropdown.prototype.renderData = function(data, group) { - return data.map((obj, index) => this.renderItem(obj, group || false, index)); - }; - - GitLabDropdown.prototype.shouldPropagate = function(e) { - var $target; - if (this.options.multiSelect || this.options.shouldPropagate === false) { - $target = $(e.target); - if ( - $target && - !$target.hasClass('dropdown-menu-close') && - !$target.hasClass('dropdown-menu-close-icon') && - !$target.data('isLink') - ) { - e.stopPropagation(); - - // This prevents automatic scrolling to the top - if ($target.closest('a').length) { - return false; - } - } + } + // Render the full menu + const full_html = this.renderMenu(html); + return this.appendMenu(full_html); +}; + +GitLabDropdown.prototype.renderData = function(data, group) { + return data.map((obj, index) => this.renderItem(obj, group || false, index)); +}; - return true; +GitLabDropdown.prototype.shouldPropagate = function(e) { + let $target; + if (this.options.multiSelect || this.options.shouldPropagate === false) { + $target = $(e.target); + if ( + $target && + !$target.hasClass('dropdown-menu-close') && + !$target.hasClass('dropdown-menu-close-icon') && + !$target.data('isLink') + ) { + e.stopPropagation(); + + // This prevents automatic scrolling to the top + if ($target.closest('a').length) { + return false; + } } - }; - - GitLabDropdown.prototype.filteredFullData = function() { - return this.fullData.filter( - r => - typeof r === 'object' && - !Object.prototype.hasOwnProperty.call(r, 'beforeDivider') && - !Object.prototype.hasOwnProperty.call(r, 'header'), - ); - }; - GitLabDropdown.prototype.opened = function(e) { - var contentHtml; - this.resetRows(); - this.addArrowKeyEvent(); + return true; + } +}; - const dropdownToggle = this.dropdown.find('.dropdown-menu-toggle'); - const hasFilterBulkUpdate = dropdownToggle.hasClass('js-filter-bulk-update'); - const shouldRefreshOnOpen = dropdownToggle.hasClass('js-gl-dropdown-refresh-on-open'); - const hasMultiSelect = dropdownToggle.hasClass('js-multiselect'); +GitLabDropdown.prototype.filteredFullData = function() { + return this.fullData.filter( + r => + typeof r === 'object' && + !Object.prototype.hasOwnProperty.call(r, 'beforeDivider') && + !Object.prototype.hasOwnProperty.call(r, 'header'), + ); +}; - // Makes indeterminate items effective - if (this.fullData && (shouldRefreshOnOpen || hasFilterBulkUpdate)) { - this.parseData(this.fullData); - } +GitLabDropdown.prototype.opened = function(e) { + this.resetRows(); + this.addArrowKeyEvent(); - // Process the data to make sure rendered data - // matches the correct layout - const inputValue = this.filterInput.val(); - if (this.fullData && hasMultiSelect && this.options.processData && inputValue.length === 0) { - this.options.processData.call( - this.options, - inputValue, - this.filteredFullData(), - this.parseData.bind(this), - ); - } + const dropdownToggle = this.dropdown.find('.dropdown-menu-toggle'); + const hasFilterBulkUpdate = dropdownToggle.hasClass('js-filter-bulk-update'); + const shouldRefreshOnOpen = dropdownToggle.hasClass('js-gl-dropdown-refresh-on-open'); + const hasMultiSelect = dropdownToggle.hasClass('js-multiselect'); - contentHtml = $('.dropdown-content', this.dropdown).html(); - if (this.remote && contentHtml === '') { - this.remote.execute(); + // Makes indeterminate items effective + if (this.fullData && (shouldRefreshOnOpen || hasFilterBulkUpdate)) { + this.parseData(this.fullData); + } + + // Process the data to make sure rendered data + // matches the correct layout + const inputValue = this.filterInput.val(); + if (this.fullData && hasMultiSelect && this.options.processData && inputValue.length === 0) { + this.options.processData.call( + this.options, + inputValue, + this.filteredFullData(), + this.parseData.bind(this), + ); + } + + const contentHtml = $('.dropdown-content', this.dropdown).html(); + if (this.remote && contentHtml === '') { + this.remote.execute(); + } else { + this.focusTextInput(); + } + + if (this.options.showMenuAbove) { + this.positionMenuAbove(); + } + + if (this.options.opened) { + if (this.options.preserveContext) { + this.options.opened(e); } else { - this.focusTextInput(); + this.options.opened.call(this, e); } + } - if (this.options.showMenuAbove) { - this.positionMenuAbove(); - } + return this.dropdown.trigger('shown.gl.dropdown'); +}; - if (this.options.opened) { - if (this.options.preserveContext) { - this.options.opened(e); - } else { - this.options.opened.call(this, e); - } - } +GitLabDropdown.prototype.positionMenuAbove = function() { + const $menu = this.dropdown.find('.dropdown-menu'); - return this.dropdown.trigger('shown.gl.dropdown'); - }; + $menu.addClass('dropdown-open-top'); + $menu.css('top', 'initial'); + $menu.css('bottom', '100%'); +}; - GitLabDropdown.prototype.positionMenuAbove = function() { - var $menu = this.dropdown.find('.dropdown-menu'); +GitLabDropdown.prototype.hidden = function(e) { + this.resetRows(); + this.removeArrowKeyEvent(); + const $input = this.dropdown.find('.dropdown-input-field'); + if (this.options.filterable) { + $input.blur(); + } + if (this.dropdown.find('.dropdown-toggle-page').length) { + $('.dropdown-menu', this.dropdown).removeClass(PAGE_TWO_CLASS); + } + if (this.options.hidden) { + this.options.hidden.call(this, e); + } + return this.dropdown.trigger('hidden.gl.dropdown'); +}; - $menu.addClass('dropdown-open-top'); - $menu.css('top', 'initial'); - $menu.css('bottom', '100%'); - }; +// Render the full menu +GitLabDropdown.prototype.renderMenu = function(html) { + if (this.options.renderMenu) { + return this.options.renderMenu(html); + } else { + return $('<ul>').append(html); + } +}; - GitLabDropdown.prototype.hidden = function(e) { - var $input; - this.resetRows(); - this.removeArrowKeyEvent(); - $input = this.dropdown.find('.dropdown-input-field'); - if (this.options.filterable) { - $input.blur(); - } - if (this.dropdown.find('.dropdown-toggle-page').length) { - $('.dropdown-menu', this.dropdown).removeClass(PAGE_TWO_CLASS); - } - if (this.options.hidden) { - this.options.hidden.call(this, e); - } - return this.dropdown.trigger('hidden.gl.dropdown'); - }; +// Append the menu into the dropdown +GitLabDropdown.prototype.appendMenu = function(html) { + return this.clearMenu().append(html); +}; - // Render the full menu - GitLabDropdown.prototype.renderMenu = function(html) { - if (this.options.renderMenu) { - return this.options.renderMenu(html); +GitLabDropdown.prototype.clearMenu = function() { + let selector; + selector = '.dropdown-content'; + if (this.dropdown.find('.dropdown-toggle-page').length) { + if (this.options.containerSelector) { + selector = this.options.containerSelector; } else { - return $('<ul>').append(html); + selector = '.dropdown-page-one .dropdown-content'; } - }; + } - // Append the menu into the dropdown - GitLabDropdown.prototype.appendMenu = function(html) { - return this.clearMenu().append(html); - }; + return $(selector, this.dropdown).empty(); +}; - GitLabDropdown.prototype.clearMenu = function() { - var selector; - selector = '.dropdown-content'; - if (this.dropdown.find('.dropdown-toggle-page').length) { - if (this.options.containerSelector) { - selector = this.options.containerSelector; - } else { - selector = '.dropdown-page-one .dropdown-content'; - } - } +GitLabDropdown.prototype.renderItem = function(data, group, index) { + let parent; - return $(selector, this.dropdown).empty(); - }; + if (this.dropdown && this.dropdown[0]) { + parent = this.dropdown[0].parentNode; + } - GitLabDropdown.prototype.renderItem = function(data, group, index) { - let parent; + return renderItem({ + instance: this, + options: Object.assign({}, this.options, { + icon: this.icon, + highlight: this.highlight, + highlightText: text => this.highlightTextMatches(text, this.filterInput.val()), + highlightTemplate: this.highlightTemplate.bind(this), + parent, + }), + data, + group, + index, + }); +}; - if (this.dropdown && this.dropdown[0]) { - parent = this.dropdown[0].parentNode; - } +GitLabDropdown.prototype.highlightTemplate = function(text, template) { + return `"<b>${_.escape(text)}</b>" ${template}`; +}; - return renderItem({ - instance: this, - options: Object.assign({}, this.options, { - icon: this.icon, - highlight: this.highlight, - highlightText: text => this.highlightTextMatches(text, this.filterInput.val()), - highlightTemplate: this.highlightTemplate.bind(this), - parent, - }), - data, - group, - index, - }); - }; - - GitLabDropdown.prototype.highlightTemplate = function(text, template) { - return `"<b>${_.escape(text)}</b>" ${template}`; - }; - - GitLabDropdown.prototype.highlightTextMatches = function(text, term) { - const occurrences = fuzzaldrinPlus.match(text, term); - const { indexOf } = []; - - return text - .split('') - .map((character, i) => { - if (indexOf.call(occurrences, i) !== -1) { - return `<b>${character}</b>`; - } else { - return character; - } - }) - .join(''); - }; - - GitLabDropdown.prototype.noResults = function() { - return '<li class="dropdown-menu-empty-item"><a>No matching results</a></li>'; - }; - - GitLabDropdown.prototype.rowClicked = function(el) { - var field, groupName, isInput, selectedIndex, selectedObject, value, isMarking; - - const { fieldName } = this.options; - isInput = $(this.el).is('input'); - if (this.renderedData) { - groupName = el.data('group'); - if (groupName) { - selectedIndex = el.data('index'); - selectedObject = this.renderedData[groupName][selectedIndex]; - } else { - selectedIndex = el.closest('li').index(); - this.selectedIndex = selectedIndex; - selectedObject = this.renderedData[selectedIndex]; - } - } +GitLabDropdown.prototype.highlightTextMatches = function(text, term) { + const occurrences = fuzzaldrinPlus.match(text, term); + const { indexOf } = []; - if (this.options.vue) { - if (el.hasClass(ACTIVE_CLASS)) { - el.removeClass(ACTIVE_CLASS); + return text + .split('') + .map((character, i) => { + if (indexOf.call(occurrences, i) !== -1) { + return `<b>${character}</b>`; } else { - el.addClass(ACTIVE_CLASS); + return character; } + }) + .join(''); +}; - return [selectedObject]; - } - - field = []; - value = this.options.id ? this.options.id(selectedObject, el) : selectedObject.id; - if (isInput) { - field = $(this.el); - } else if (value != null) { - field = this.dropdown - .parent() - .find(`input[name='${fieldName}'][value='${value.toString().replace(/'/g, "\\'")}']`); - } +GitLabDropdown.prototype.noResults = function() { + return '<li class="dropdown-menu-empty-item"><a>No matching results</a></li>'; +}; - if (this.options.isSelectable && !this.options.isSelectable(selectedObject, el)) { - return [selectedObject]; +GitLabDropdown.prototype.rowClicked = function(el) { + let field, groupName, selectedIndex, selectedObject, isMarking; + const { fieldName } = this.options; + const isInput = $(this.el).is('input'); + if (this.renderedData) { + groupName = el.data('group'); + if (groupName) { + selectedIndex = el.data('index'); + selectedObject = this.renderedData[groupName][selectedIndex]; + } else { + selectedIndex = el.closest('li').index(); + this.selectedIndex = selectedIndex; + selectedObject = this.renderedData[selectedIndex]; } + } - if (el.hasClass(ACTIVE_CLASS) && value !== 0) { - isMarking = false; + if (this.options.vue) { + if (el.hasClass(ACTIVE_CLASS)) { el.removeClass(ACTIVE_CLASS); - if (field && field.length) { - this.clearField(field, isInput); - } - } else if (el.hasClass(INDETERMINATE_CLASS)) { - isMarking = true; - el.addClass(ACTIVE_CLASS); - el.removeClass(INDETERMINATE_CLASS); - if (field && field.length && value == null) { - this.clearField(field, isInput); - } - if ((!field || !field.length) && fieldName) { - this.addInput(fieldName, value, selectedObject); - } } else { - isMarking = true; - if (!this.options.multiSelect || el.hasClass('dropdown-clear-active')) { - this.dropdown.find(`.${ACTIVE_CLASS}`).removeClass(ACTIVE_CLASS); - if (!isInput) { - this.dropdown - .parent() - .find(`input[name='${fieldName}']`) - .remove(); - } - } - if (field && field.length && value == null) { - this.clearField(field, isInput); - } - // Toggle active class for the tick mark el.addClass(ACTIVE_CLASS); - if (value != null) { - if ((!field || !field.length) && fieldName) { - this.addInput(fieldName, value, selectedObject); - } else if (field && field.length) { - field.val(value).trigger('change'); - } - } } - return [selectedObject, isMarking]; - }; + return [selectedObject]; + } + + field = []; + const value = this.options.id ? this.options.id(selectedObject, el) : selectedObject.id; + if (isInput) { + field = $(this.el); + } else if (value != null) { + field = this.dropdown + .parent() + .find(`input[name='${fieldName}'][value='${value.toString().replace(/'/g, "\\'")}']`); + } - GitLabDropdown.prototype.focusTextInput = function() { - if (this.options.filterable) { - const initialScrollTop = $(window).scrollTop(); + if (this.options.isSelectable && !this.options.isSelectable(selectedObject, el)) { + return [selectedObject]; + } - if (this.dropdown.is('.show') && !this.filterInput.is(':focus')) { - this.filterInput.focus(); + if (el.hasClass(ACTIVE_CLASS) && value !== 0) { + isMarking = false; + el.removeClass(ACTIVE_CLASS); + if (field && field.length) { + this.clearField(field, isInput); + } + } else if (el.hasClass(INDETERMINATE_CLASS)) { + isMarking = true; + el.addClass(ACTIVE_CLASS); + el.removeClass(INDETERMINATE_CLASS); + if (field && field.length && value == null) { + this.clearField(field, isInput); + } + if ((!field || !field.length) && fieldName) { + this.addInput(fieldName, value, selectedObject); + } + } else { + isMarking = true; + if (!this.options.multiSelect || el.hasClass('dropdown-clear-active')) { + this.dropdown.find(`.${ACTIVE_CLASS}`).removeClass(ACTIVE_CLASS); + if (!isInput) { + this.dropdown + .parent() + .find(`input[name='${fieldName}']`) + .remove(); } - - if ($(window).scrollTop() < initialScrollTop) { - $(window).scrollTop(initialScrollTop); + } + if (field && field.length && value == null) { + this.clearField(field, isInput); + } + // Toggle active class for the tick mark + el.addClass(ACTIVE_CLASS); + if (value != null) { + if ((!field || !field.length) && fieldName) { + this.addInput(fieldName, value, selectedObject); + } else if (field && field.length) { + field.val(value).trigger('change'); } } - }; + } - GitLabDropdown.prototype.addInput = function(fieldName, value, selectedObject, single) { - var $input; - // Create hidden input for form - if (single) { - $(`input[name="${fieldName}"]`).remove(); - } + return [selectedObject, isMarking]; +}; - $input = $('<input>') - .attr('type', 'hidden') - .attr('name', fieldName) - .val(value); - if (this.options.inputId != null) { - $input.attr('id', this.options.inputId); - } +GitLabDropdown.prototype.focusTextInput = function() { + if (this.options.filterable) { + const initialScrollTop = $(window).scrollTop(); - if (this.options.multiSelect) { - Object.keys(selectedObject).forEach(attribute => { - $input.attr(`data-${attribute}`, selectedObject[attribute]); - }); + if (this.dropdown.is('.show') && !this.filterInput.is(':focus')) { + this.filterInput.focus(); } - if (this.options.inputMeta) { - $input.attr('data-meta', selectedObject[this.options.inputMeta]); + if ($(window).scrollTop() < initialScrollTop) { + $(window).scrollTop(initialScrollTop); } + } +}; + +GitLabDropdown.prototype.addInput = function(fieldName, value, selectedObject, single) { + // Create hidden input for form + if (single) { + $(`input[name="${fieldName}"]`).remove(); + } + + const $input = $('<input>') + .attr('type', 'hidden') + .attr('name', fieldName) + .val(value); + if (this.options.inputId != null) { + $input.attr('id', this.options.inputId); + } - this.dropdown.before($input).trigger('change'); - }; + if (this.options.multiSelect) { + Object.keys(selectedObject).forEach(attribute => { + $input.attr(`data-${attribute}`, selectedObject[attribute]); + }); + } + + if (this.options.inputMeta) { + $input.attr('data-meta', selectedObject[this.options.inputMeta]); + } - GitLabDropdown.prototype.selectRowAtIndex = function(index) { - var $el, selector; - // If we pass an option index - if (typeof index !== 'undefined') { - selector = `${SELECTABLE_CLASSES}:eq(${index}) a`; + this.dropdown.before($input).trigger('change'); +}; + +GitLabDropdown.prototype.selectRowAtIndex = function(index) { + let selector; + // If we pass an option index + if (typeof index !== 'undefined') { + selector = `${SELECTABLE_CLASSES}:eq(${index}) a`; + } else { + selector = '.dropdown-content .is-focused'; + } + if (this.dropdown.find('.dropdown-toggle-page').length) { + selector = `.dropdown-page-one ${selector}`; + } + // simulate a click on the first link + const $el = $(selector, this.dropdown); + if ($el.length) { + const href = $el.attr('href'); + if (href && href !== '#') { + visitUrl(href); } else { - selector = '.dropdown-content .is-focused'; + $el.trigger('click'); } - if (this.dropdown.find('.dropdown-toggle-page').length) { - selector = `.dropdown-page-one ${selector}`; - } - // simulate a click on the first link - $el = $(selector, this.dropdown); - if ($el.length) { - var href = $el.attr('href'); - if (href && href !== '#') { - visitUrl(href); - } else { - $el.trigger('click'); - } - } - }; + } +}; - GitLabDropdown.prototype.addArrowKeyEvent = function() { - var ARROW_KEY_CODES, selector; - ARROW_KEY_CODES = [38, 40]; - selector = SELECTABLE_CLASSES; - if (this.dropdown.find('.dropdown-toggle-page').length) { - selector = `.dropdown-page-one ${selector}`; +GitLabDropdown.prototype.addArrowKeyEvent = function() { + let selector; + const ARROW_KEY_CODES = [38, 40]; + selector = SELECTABLE_CLASSES; + if (this.dropdown.find('.dropdown-toggle-page').length) { + selector = `.dropdown-page-one ${selector}`; + } + return $('body').on('keydown', e => { + let $listItems, PREV_INDEX; + const currentKeyCode = e.which; + if (ARROW_KEY_CODES.indexOf(currentKeyCode) !== -1) { + e.preventDefault(); + e.stopImmediatePropagation(); + PREV_INDEX = currentIndex; + $listItems = $(selector, this.dropdown); + // if @options.filterable + // $input.blur() + if (currentKeyCode === 40) { + // Move down + if (currentIndex < $listItems.length - 1) { + currentIndex += 1; + } + } else if (currentKeyCode === 38) { + // Move up + if (currentIndex > 0) { + currentIndex -= 1; + } + } + if (currentIndex !== PREV_INDEX) { + this.highlightRowAtIndex($listItems, currentIndex); + } + return false; } - return $('body').on( - 'keydown', - (function(_this) { - return function(e) { - var $listItems, PREV_INDEX, currentKeyCode; - currentKeyCode = e.which; - if (ARROW_KEY_CODES.indexOf(currentKeyCode) !== -1) { - e.preventDefault(); - e.stopImmediatePropagation(); - PREV_INDEX = currentIndex; - $listItems = $(selector, _this.dropdown); - // if @options.filterable - // $input.blur() - if (currentKeyCode === 40) { - // Move down - if (currentIndex < $listItems.length - 1) { - currentIndex += 1; - } - } else if (currentKeyCode === 38) { - // Move up - if (currentIndex > 0) { - currentIndex -= 1; - } - } - if (currentIndex !== PREV_INDEX) { - _this.highlightRowAtIndex($listItems, currentIndex); - } - return false; - } - if (currentKeyCode === 13 && currentIndex !== -1) { - e.preventDefault(); - _this.selectRowAtIndex(); - } - }; - })(this), - ); - }; - - GitLabDropdown.prototype.removeArrowKeyEvent = function() { - return $('body').off('keydown'); - }; - - GitLabDropdown.prototype.resetRows = function resetRows() { - currentIndex = -1; - $('.is-focused', this.dropdown).removeClass('is-focused'); - }; - - GitLabDropdown.prototype.highlightRowAtIndex = function($listItems, index) { - var $dropdownContent, - $listItem, - dropdownContentBottom, - dropdownContentHeight, - dropdownContentTop, - dropdownScrollTop, - listItemBottom, - listItemHeight, - listItemTop; - - if (!$listItems) { - $listItems = $(SELECTABLE_CLASSES, this.dropdown); + if (currentKeyCode === 13 && currentIndex !== -1) { + e.preventDefault(); + this.selectRowAtIndex(); } + }); +}; - // Remove the class for the previously focused row - $('.is-focused', this.dropdown).removeClass('is-focused'); - // Update the class for the row at the specific index - $listItem = $listItems.eq(index); - $listItem.find('a:first-child').addClass('is-focused'); - // Dropdown content scroll area - $dropdownContent = $listItem.closest('.dropdown-content'); - dropdownScrollTop = $dropdownContent.scrollTop(); - dropdownContentHeight = $dropdownContent.outerHeight(); - dropdownContentTop = $dropdownContent.prop('offsetTop'); - dropdownContentBottom = dropdownContentTop + dropdownContentHeight; - // Get the offset bottom of the list item - listItemHeight = $listItem.outerHeight(); - listItemTop = $listItem.prop('offsetTop'); - listItemBottom = listItemTop + listItemHeight; - if (!index) { - // Scroll the dropdown content to the top - $dropdownContent.scrollTop(0); - } else if (index === $listItems.length - 1) { - // Scroll the dropdown content to the bottom - $dropdownContent.scrollTop($dropdownContent.prop('scrollHeight')); - } else if (listItemBottom > dropdownContentBottom + dropdownScrollTop) { - // Scroll the dropdown content down - $dropdownContent.scrollTop( - listItemBottom - dropdownContentBottom + CURSOR_SELECT_SCROLL_PADDING, - ); - } else if (listItemTop < dropdownContentTop + dropdownScrollTop) { - // Scroll the dropdown content up - return $dropdownContent.scrollTop( - listItemTop - dropdownContentTop - CURSOR_SELECT_SCROLL_PADDING, - ); - } - }; +GitLabDropdown.prototype.removeArrowKeyEvent = function() { + return $('body').off('keydown'); +}; - GitLabDropdown.prototype.updateLabel = function(selected, el, instance) { - if (selected == null) { - selected = null; - } - if (el == null) { - el = null; - } - if (instance == null) { - instance = null; - } +GitLabDropdown.prototype.resetRows = function resetRows() { + currentIndex = -1; + $('.is-focused', this.dropdown).removeClass('is-focused'); +}; - let toggleText = this.options.toggleLabel(selected, el, instance); - if (this.options.updateLabel) { - // Option to override the dropdown label text - toggleText = this.options.updateLabel; - } +GitLabDropdown.prototype.highlightRowAtIndex = function($listItems, index) { + if (!$listItems) { + $listItems = $(SELECTABLE_CLASSES, this.dropdown); + } + + // Remove the class for the previously focused row + $('.is-focused', this.dropdown).removeClass('is-focused'); + // Update the class for the row at the specific index + const $listItem = $listItems.eq(index); + $listItem.find('a:first-child').addClass('is-focused'); + // Dropdown content scroll area + const $dropdownContent = $listItem.closest('.dropdown-content'); + const dropdownScrollTop = $dropdownContent.scrollTop(); + const dropdownContentHeight = $dropdownContent.outerHeight(); + const dropdownContentTop = $dropdownContent.prop('offsetTop'); + const dropdownContentBottom = dropdownContentTop + dropdownContentHeight; + // Get the offset bottom of the list item + const listItemHeight = $listItem.outerHeight(); + const listItemTop = $listItem.prop('offsetTop'); + const listItemBottom = listItemTop + listItemHeight; + if (!index) { + // Scroll the dropdown content to the top + $dropdownContent.scrollTop(0); + } else if (index === $listItems.length - 1) { + // Scroll the dropdown content to the bottom + $dropdownContent.scrollTop($dropdownContent.prop('scrollHeight')); + } else if (listItemBottom > dropdownContentBottom + dropdownScrollTop) { + // Scroll the dropdown content down + $dropdownContent.scrollTop( + listItemBottom - dropdownContentBottom + CURSOR_SELECT_SCROLL_PADDING, + ); + } else if (listItemTop < dropdownContentTop + dropdownScrollTop) { + // Scroll the dropdown content up + return $dropdownContent.scrollTop( + listItemTop - dropdownContentTop - CURSOR_SELECT_SCROLL_PADDING, + ); + } +}; + +GitLabDropdown.prototype.updateLabel = function(selected, el, instance) { + if (selected == null) { + selected = null; + } + if (el == null) { + el = null; + } + if (instance == null) { + instance = null; + } - return $(this.el) - .find('.dropdown-toggle-text') - .text(toggleText); - }; + let toggleText = this.options.toggleLabel(selected, el, instance); + if (this.options.updateLabel) { + // Option to override the dropdown label text + toggleText = this.options.updateLabel; + } - GitLabDropdown.prototype.clearField = function(field, isInput) { - return isInput ? field.val('') : field.remove(); - }; + return $(this.el) + .find('.dropdown-toggle-text') + .text(toggleText); +}; - return GitLabDropdown; -})(); +GitLabDropdown.prototype.clearField = function(field, isInput) { + return isInput ? field.val('') : field.remove(); +}; $.fn.glDropdown = function(opts) { return this.each(function() { diff --git a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue index bd504d95ee2..6258ee7f153 100644 --- a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue +++ b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlFormGroup, GlFormInput, GlFormCheckbox, GlLink } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; import { mapState, mapActions } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; export default { components: { diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index 8d2dac47ff2..ce6591e85cf 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -2,13 +2,13 @@ /* global Flash */ import $ from 'jquery'; +import { GlLoadingIcon } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; import { HIDDEN_CLASS } from '~/lib/utils/constants'; import { getParameterByName } from '~/lib/utils/common_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; -import { GlLoadingIcon } from '@gitlab/ui'; import eventHub from '../event_hub'; import { COMMON_STR, CONTENT_LIST_CLASS } from '../constants'; import groupsComponent from './groups.vue'; diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue index 734a9a89c72..675552e6c2b 100644 --- a/app/assets/javascripts/groups/components/item_stats.vue +++ b/app/assets/javascripts/groups/components/item_stats.vue @@ -1,5 +1,6 @@ <script> import icon from '~/vue_shared/components/icon.vue'; +import { GlBadge } from '@gitlab/ui'; import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { ITEM_TYPE, @@ -8,13 +9,16 @@ import { PROJECT_VISIBILITY_TYPE, } from '../constants'; import itemStatsValue from './item_stats_value.vue'; +import isProjectPendingRemoval from 'ee_else_ce/groups/mixins/is_project_pending_removal'; export default { components: { icon, timeAgoTooltip, itemStatsValue, + GlBadge, }, + mixins: [isProjectPendingRemoval], props: { item: { type: Object, @@ -70,6 +74,9 @@ export default { css-class="project-stars" icon-name="star" /> + <div v-if="isProjectPendingRemoval"> + <gl-badge variant="warning">{{ __('pending removal') }}</gl-badge> + </div> <div v-if="isProject" class="last-updated"> <time-ago-tooltip :time="item.updatedAt" tooltip-placement="bottom" /> </div> diff --git a/app/assets/javascripts/groups/mixins/is_project_pending_removal.js b/app/assets/javascripts/groups/mixins/is_project_pending_removal.js new file mode 100644 index 00000000000..e44e5780199 --- /dev/null +++ b/app/assets/javascripts/groups/mixins/is_project_pending_removal.js @@ -0,0 +1,7 @@ +export default { + computed: { + isProjectPendingRemoval() { + return false; + }, + }, +}; diff --git a/app/assets/javascripts/groups/store/groups_store.js b/app/assets/javascripts/groups/store/groups_store.js index 16f95d5a0cc..214ac5e3db5 100644 --- a/app/assets/javascripts/groups/store/groups_store.js +++ b/app/assets/javascripts/groups/store/groups_store.js @@ -93,6 +93,7 @@ export default class GroupsStore { memberCount: rawGroupItem.number_users_with_delimiter, starCount: rawGroupItem.star_count, updatedAt: rawGroupItem.updated_at, + pendingRemoval: rawGroupItem.marked_for_deletion_at, }; } diff --git a/app/assets/javascripts/helpers/monitor_helper.js b/app/assets/javascripts/helpers/monitor_helper.js index d172aa8a444..87b4b14f6bf 100644 --- a/app/assets/javascripts/helpers/monitor_helper.js +++ b/app/assets/javascripts/helpers/monitor_helper.js @@ -7,6 +7,7 @@ export const makeDataSeries = (queryResults, defaultConfig) => queryResults .map(result => { + // NaN values may disrupt avg., max. & min. calculations in the legend, filter them out const data = result.values.filter(([, value]) => !Number.isNaN(value)); if (!data.length) { return null; diff --git a/app/assets/javascripts/ide/.eslintrc.yml b/app/assets/javascripts/ide/.eslintrc.yml index 92b96d717be..2af5c1798f5 100644 --- a/app/assets/javascripts/ide/.eslintrc.yml +++ b/app/assets/javascripts/ide/.eslintrc.yml @@ -1,3 +1,5 @@ rules: + # https://gitlab.com/gitlab-org/gitlab/issues/28717 + import/no-cycle: off # https://gitlab.com/gitlab-org/gitlab/issues/33024 promise/no-nesting: off diff --git a/app/assets/javascripts/ide/components/branches/search_list.vue b/app/assets/javascripts/ide/components/branches/search_list.vue index db8365a08e0..31f1dec43ad 100644 --- a/app/assets/javascripts/ide/components/branches/search_list.vue +++ b/app/assets/javascripts/ide/components/branches/search_list.vue @@ -1,8 +1,8 @@ <script> import { mapActions, mapState } from 'vuex'; import _ from 'underscore'; -import Icon from '~/vue_shared/components/icon.vue'; import { GlLoadingIcon } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; import Item from './item.vue'; export default { diff --git a/app/assets/javascripts/ide/components/file_templates/dropdown.vue b/app/assets/javascripts/ide/components/file_templates/dropdown.vue index 343e0cca672..35e5f9bcf69 100644 --- a/app/assets/javascripts/ide/components/file_templates/dropdown.vue +++ b/app/assets/javascripts/ide/components/file_templates/dropdown.vue @@ -1,8 +1,8 @@ <script> import $ from 'jquery'; import { mapActions, mapState } from 'vuex'; -import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; import { GlLoadingIcon } from '@gitlab/ui'; +import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; export default { components: { diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index 326589fa50f..6eaf08e8033 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -22,7 +22,7 @@ export default { mixins: [timeAgoMixin], data() { return { - lastCommitFormatedAge: null, + lastCommitFormattedAge: null, }; }, computed: { @@ -62,7 +62,7 @@ export default { }, commitAgeUpdate() { if (this.lastCommit) { - this.lastCommitFormatedAge = this.timeFormated(this.lastCommit.committed_date); + this.lastCommitFormattedAge = this.timeFormatted(this.lastCommit.committed_date); } }, getCommitPath(shortSha) { @@ -118,7 +118,7 @@ export default { :title="tooltipTitle(lastCommit.committed_date)" data-placement="top" data-container="body" - >{{ lastCommitFormatedAge }}</time + >{{ lastCommitFormattedAge }}</time > </div> <ide-status-list class="ml-auto" /> diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue index 95782b2c88a..3a0dd60f0e0 100644 --- a/app/assets/javascripts/ide/components/ide_tree_list.vue +++ b/app/assets/javascripts/ide/components/ide_tree_list.vue @@ -1,7 +1,7 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; -import Icon from '~/vue_shared/components/icon.vue'; import { GlSkeletonLoading } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; import FileRow from '~/vue_shared/components/file_row.vue'; import NavDropdown from './nav_dropdown.vue'; import FileRowExtra from './file_row_extra.vue'; diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue index 5daf2d1422c..5a8face062b 100644 --- a/app/assets/javascripts/ide/components/merge_requests/list.vue +++ b/app/assets/javascripts/ide/components/merge_requests/list.vue @@ -1,9 +1,9 @@ <script> import { mapActions, mapState } from 'vuex'; import _ from 'underscore'; +import { GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; -import { GlLoadingIcon } from '@gitlab/ui'; import Item from './item.vue'; import TokenedInput from '../shared/tokened_input.vue'; diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index d2ed1fe3e55..ecafb4e81c4 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -1,8 +1,8 @@ <script> import $ from 'jquery'; +import { mapActions, mapState, mapGetters } from 'vuex'; import flash from '~/flash'; import { __, sprintf, s__ } from '~/locale'; -import { mapActions, mapState, mapGetters } from 'vuex'; import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import { modalTypes } from '../../constants'; diff --git a/app/assets/javascripts/ide/components/preview/navigator.vue b/app/assets/javascripts/ide/components/preview/navigator.vue index bc80e1dba25..ff23485f0f0 100644 --- a/app/assets/javascripts/ide/components/preview/navigator.vue +++ b/app/assets/javascripts/ide/components/preview/navigator.vue @@ -1,7 +1,7 @@ <script> import { listen } from 'codesandbox-api'; -import Icon from '~/vue_shared/components/icon.vue'; import { GlLoadingIcon } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; export default { components: { diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue index 7615cfc966e..8370833233a 100644 --- a/app/assets/javascripts/ide/components/repo_tab.vue +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -1,6 +1,6 @@ <script> -import { __, sprintf } from '~/locale'; import { mapActions } from 'vuex'; +import { __, sprintf } from '~/locale'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import Icon from '~/vue_shared/components/icon.vue'; diff --git a/app/assets/javascripts/ide/lib/files.js b/app/assets/javascripts/ide/lib/files.js index e86dac20104..bee867fa47c 100644 --- a/app/assets/javascripts/ide/lib/files.js +++ b/app/assets/javascripts/ide/lib/files.js @@ -1,5 +1,6 @@ import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; -import { decorateData, sortTree, escapeFileUrl } from '../stores/utils'; +import { escapeFileUrl } from '~/lib/utils/url_utility'; +import { decorateData, sortTree } from '../stores/utils'; export const splitParent = path => { const idx = path.lastIndexOf('/'); diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index f6ad2f9c7d1..b130e6e8b81 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -1,6 +1,5 @@ import axios from '~/lib/utils/axios_utils'; -import { joinPaths } from '~/lib/utils/url_utility'; -import { escapeFileUrl } from '../stores/utils'; +import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; import Api from '~/api'; export default { diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index 4e18ec58feb..dd69e2d6f1f 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -1,9 +1,9 @@ import $ from 'jquery'; import Vue from 'vue'; +import _ from 'underscore'; import { __, sprintf } from '~/locale'; import { visitUrl } from '~/lib/utils/url_utility'; import flash from '~/flash'; -import _ from 'underscore'; import * as types from './mutation_types'; import { decorateFiles } from '../lib/files'; import { stageKeys } from '../constants'; @@ -17,10 +17,18 @@ export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DAT export const discardAllChanges = ({ state, commit, dispatch }) => { state.changedFiles.forEach(file => { - commit(types.DISCARD_FILE_CHANGES, file.path); + if (file.tempFile || file.prevPath) dispatch('closeFile', file); if (file.tempFile) { - dispatch('closeFile', file.path); + dispatch('deleteEntry', file.path); + } else if (file.prevPath) { + dispatch('renameEntry', { + path: file.path, + name: file.prevName, + parentPath: file.prevParentPath, + }); + } else { + commit(types.DISCARD_FILE_CHANGES, file.path); } }); diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 9af0b50d1a5..8864224c19e 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -1,10 +1,10 @@ -import { joinPaths } from '~/lib/utils/url_utility'; +import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import eventHub from '../../eventhub'; import service from '../../services'; import * as types from '../mutation_types'; import router from '../../ide_router'; -import { escapeFileUrl, addFinalNewlineIfNeeded, setPageTitleForFile } from '../utils'; +import { addFinalNewlineIfNeeded, setPageTitleForFile } from '../utils'; import { viewerTypes, stageKeys } from '../../constants'; export const closeFile = ({ commit, state, dispatch }, file) => { diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index a176fd0aca8..bb8374b4e78 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -115,5 +115,30 @@ export const isOnDefaultBranch = (_state, getters) => export const canPushToBranch = (_state, getters) => getters.currentBranch && getters.currentBranch.can_push; +export const isFileDeletedAndReadded = (state, getters) => path => { + const stagedFile = getters.getStagedFile(path); + const file = state.entries[path]; + return Boolean(stagedFile && stagedFile.deleted && file.tempFile); +}; + +// checks if any diff exists in the staged or unstaged changes for this path +export const getDiffInfo = (state, getters) => path => { + const stagedFile = getters.getStagedFile(path); + const file = state.entries[path]; + const renamed = file.prevPath ? file.path !== file.prevPath : false; + const deletedAndReadded = getters.isFileDeletedAndReadded(path); + const deleted = deletedAndReadded ? false : file.deleted; + const tempFile = deletedAndReadded ? false : file.tempFile; + const changed = file.content !== (deletedAndReadded ? stagedFile.raw : file.raw); + + return { + exists: changed || renamed || deleted || tempFile, + changed, + renamed, + deleted, + tempFile, + }; +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index be7ee80656f..47a2e6b5202 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -1,4 +1,5 @@ import { commitActionTypes, FILE_VIEW_MODE_EDITOR } from '../constants'; +import { escapeFileUrl } from '~/lib/utils/url_utility'; export const dataStructure = () => ({ id: '', @@ -217,8 +218,6 @@ export const mergeTrees = (fromTree, toTree) => { return toTree; }; -export const escapeFileUrl = fileUrl => encodeURIComponent(fileUrl).replace(/%2F/g, '/'); - export const replaceFileUrl = (url, oldPath, newPath) => { // Add `/-/` so that we don't accidentally replace project path const result = url.replace(`/-/${escapeFileUrl(oldPath)}`, `/-/${escapeFileUrl(newPath)}`); diff --git a/app/assets/javascripts/image_diff/.eslintrc.yml b/app/assets/javascripts/image_diff/.eslintrc.yml new file mode 100644 index 00000000000..bf9e184381e --- /dev/null +++ b/app/assets/javascripts/image_diff/.eslintrc.yml @@ -0,0 +1,3 @@ +rules: + # https://gitlab.com/gitlab-org/gitlab/issues/28719 + import/no-cycle: off diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index 7576d36f27d..1d0807dc15d 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -6,6 +6,36 @@ import UsersSelect from './users_select'; import ZenMode from './zen_mode'; import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; import { parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility'; +import { queryToObject, objectToQuery } from './lib/utils/url_utility'; + +function organizeQuery(obj, isFallbackKey = false) { + const sourceBranch = 'merge_request[source_branch]'; + const targetBranch = 'merge_request[target_branch]'; + + if (isFallbackKey) { + return { + [sourceBranch]: obj[sourceBranch], + }; + } + + return { + [sourceBranch]: obj[sourceBranch], + [targetBranch]: obj[targetBranch], + }; +} + +function format(searchTerm, isFallbackKey = false) { + const queryObject = queryToObject(searchTerm); + const organizeQueryObject = organizeQuery(queryObject, isFallbackKey); + const formattedQuery = objectToQuery(organizeQueryObject); + + return formattedQuery; +} + +function getFallbackKey() { + const searchTerm = format(document.location.search, true); + return ['autosave', document.location.pathname, searchTerm].join('/'); +} export default class IssuableForm { constructor(form) { @@ -57,16 +87,20 @@ export default class IssuableForm { } initAutosave() { - this.autosave = new Autosave(this.titleField, [ - document.location.pathname, - document.location.search, - 'title', - ]); - return new Autosave(this.descriptionField, [ - document.location.pathname, - document.location.search, - 'description', - ]); + const searchTerm = format(document.location.search); + const fallbackKey = getFallbackKey(); + + this.autosave = new Autosave( + this.titleField, + [document.location.pathname, searchTerm, 'title'], + `${fallbackKey}=title`, + ); + + return new Autosave( + this.descriptionField, + [document.location.pathname, searchTerm, 'description'], + `${fallbackKey}=description`, + ); } handleSubmit() { diff --git a/app/assets/javascripts/issuable_suggestions/components/item.vue b/app/assets/javascripts/issuable_suggestions/components/item.vue index 7629e04684c..66a4cc44d51 100644 --- a/app/assets/javascripts/issuable_suggestions/components/item.vue +++ b/app/assets/javascripts/issuable_suggestions/components/item.vue @@ -91,7 +91,7 @@ export default { /> <gl-tooltip :target="() => $refs.state" placement="bottom"> <span class="d-block"> - <span class="bold"> {{ stateTitle }} </span> {{ timeFormated(closedOrCreatedDate) }} + <span class="bold"> {{ stateTitle }} </span> {{ timeFormatted(closedOrCreatedDate) }} </span> <span class="text-tertiary">{{ tooltipTitle(closedOrCreatedDate) }}</span> </gl-tooltip> diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue index 06477477aad..415fa46835b 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -1,8 +1,8 @@ <script> -import { __, sprintf } from '~/locale'; import _ from 'underscore'; import { mapActions, mapState } from 'vuex'; import { GlLink, GlButton } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; import Icon from '~/vue_shared/components/icon.vue'; @@ -168,13 +168,13 @@ export default { /> <detail-row v-if="job.finished_at" - :value="timeFormated(job.finished_at)" + :value="timeFormatted(job.finished_at)" class="js-job-finished" title="Finished" /> <detail-row v-if="job.erased_at" - :value="timeFormated(job.erased_at)" + :value="timeFormatted(job.erased_at)" class="js-job-erased" title="Erased" /> diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue index 922f64d93fe..5edb8ff555b 100644 --- a/app/assets/javascripts/jobs/components/trigger_block.vue +++ b/app/assets/javascripts/jobs/components/trigger_block.vue @@ -1,6 +1,6 @@ <script> -import { __ } from '~/locale'; import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; const HIDDEN_VALUE = '••••••'; diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js index 179d0bc4e0f..0b28c52a78f 100644 --- a/app/assets/javascripts/jobs/store/utils.js +++ b/app/assets/javascripts/jobs/store/utils.js @@ -114,7 +114,7 @@ export const logLinesParser = (lines = [], accumulator = []) => acc.push(parseHeaderLine(line, lineNumber)); } else if (isCollapsibleSection(acc, last, line)) { // if the object belongs to a nested section, we append it to the new `lines` array of the - // previously formated header + // previously formatted header last.lines.push(parseLine(line, lineNumber)); } else if (line.section_duration) { // if the line has section_duration, we look for the correct header to add it diff --git a/app/assets/javascripts/lib/utils/axios_utils.js b/app/assets/javascripts/lib/utils/axios_utils.js index a04fe609015..4eec5bffc66 100644 --- a/app/assets/javascripts/lib/utils/axios_utils.js +++ b/app/assets/javascripts/lib/utils/axios_utils.js @@ -33,11 +33,9 @@ window.addEventListener('beforeunload', () => { // Ignore AJAX errors caused by requests // being cancelled due to browser navigation -const { gon } = window; -const featureFlagEnabled = gon && gon.features && gon.features.suppressAjaxNavigationErrors; axios.interceptors.response.use( response => response, - err => suppressAjaxErrorsDuringNavigation(err, isUserNavigating, featureFlagEnabled), + err => suppressAjaxErrorsDuringNavigation(err, isUserNavigating), ); export default axios; diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 177ae4f9838..e4001e94478 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -5,7 +5,7 @@ import $ from 'jquery'; import axios from './axios_utils'; import { getLocationHash } from './url_utility'; -import { convertToCamelCase } from './text_utility'; +import { convertToCamelCase, convertToSnakeCase } from './text_utility'; import { isObject } from './type_utility'; import breakpointInstance from '../../breakpoints'; @@ -490,6 +490,8 @@ export const historyPushState = newUrl => { */ export const parseBoolean = value => (value && value.toString()) === 'true'; +export const BACKOFF_TIMEOUT = 'BACKOFF_TIMEOUT'; + /** * @callback backOffCallback * @param {Function} next @@ -541,7 +543,7 @@ export const backOff = (fn, timeout = 60000) => { timeElapsed += nextInterval; nextInterval = Math.min(nextInterval + nextInterval, maxInterval); } else { - reject(new Error('BACKOFF_TIMEOUT')); + reject(new Error(BACKOFF_TIMEOUT)); } }; @@ -697,6 +699,22 @@ export const convertObjectPropsToCamelCase = (obj = {}, options = {}) => { }, initial); }; +/** + * Converts all the object keys to snake case + * + * @param {Object} obj Object to transform + * @returns {Object} + */ +// Follow up to add additional options param: +// https://gitlab.com/gitlab-org/gitlab/issues/39173 +export const convertObjectPropsToSnakeCase = (obj = {}) => + obj + ? Object.entries(obj).reduce( + (acc, [key, value]) => ({ ...acc, [convertToSnakeCase(key)]: value }), + {}, + ) + : {}; + export const imagePath = imgUrl => `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/${imgUrl}`; diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 28143859e4c..996692bacb3 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import _ from 'underscore'; -import timeago from 'timeago.js'; +import * as timeago from 'timeago.js'; import dateFormat from 'dateformat'; import { languageCode, s__, __, n__ } from '../../locale'; @@ -92,90 +92,80 @@ export const formatDate = (datetime, format = 'mmm d, yyyy h:MMtt Z') => { */ const timeagoLanguageCode = languageCode().replace(/-/g, '_'); -let timeagoInstance; - /** - * Sets a timeago Instance + * Registers timeago locales */ -export const getTimeago = () => { - if (!timeagoInstance) { - const memoizedLocaleRemaining = () => { - const cache = []; - - const timeAgoLocaleRemaining = [ - () => [s__('Timeago|just now'), s__('Timeago|right now')], - () => [s__('Timeago|just now'), s__('Timeago|%s seconds remaining')], - () => [s__('Timeago|1 minute ago'), s__('Timeago|1 minute remaining')], - () => [s__('Timeago|%s minutes ago'), s__('Timeago|%s minutes remaining')], - () => [s__('Timeago|1 hour ago'), s__('Timeago|1 hour remaining')], - () => [s__('Timeago|%s hours ago'), s__('Timeago|%s hours remaining')], - () => [s__('Timeago|1 day ago'), s__('Timeago|1 day remaining')], - () => [s__('Timeago|%s days ago'), s__('Timeago|%s days remaining')], - () => [s__('Timeago|1 week ago'), s__('Timeago|1 week remaining')], - () => [s__('Timeago|%s weeks ago'), s__('Timeago|%s weeks remaining')], - () => [s__('Timeago|1 month ago'), s__('Timeago|1 month remaining')], - () => [s__('Timeago|%s months ago'), s__('Timeago|%s months remaining')], - () => [s__('Timeago|1 year ago'), s__('Timeago|1 year remaining')], - () => [s__('Timeago|%s years ago'), s__('Timeago|%s years remaining')], - ]; - - return (number, index) => { - if (cache[index]) { - return cache[index]; - } - cache[index] = timeAgoLocaleRemaining[index] && timeAgoLocaleRemaining[index](); - return cache[index]; - }; - }; - - const memoizedLocale = () => { - const cache = []; - - const timeAgoLocale = [ - () => [s__('Timeago|just now'), s__('Timeago|right now')], - () => [s__('Timeago|just now'), s__('Timeago|in %s seconds')], - () => [s__('Timeago|1 minute ago'), s__('Timeago|in 1 minute')], - () => [s__('Timeago|%s minutes ago'), s__('Timeago|in %s minutes')], - () => [s__('Timeago|1 hour ago'), s__('Timeago|in 1 hour')], - () => [s__('Timeago|%s hours ago'), s__('Timeago|in %s hours')], - () => [s__('Timeago|1 day ago'), s__('Timeago|in 1 day')], - () => [s__('Timeago|%s days ago'), s__('Timeago|in %s days')], - () => [s__('Timeago|1 week ago'), s__('Timeago|in 1 week')], - () => [s__('Timeago|%s weeks ago'), s__('Timeago|in %s weeks')], - () => [s__('Timeago|1 month ago'), s__('Timeago|in 1 month')], - () => [s__('Timeago|%s months ago'), s__('Timeago|in %s months')], - () => [s__('Timeago|1 year ago'), s__('Timeago|in 1 year')], - () => [s__('Timeago|%s years ago'), s__('Timeago|in %s years')], - ]; - - return (number, index) => { - if (cache[index]) { - return cache[index]; - } - cache[index] = timeAgoLocale[index] && timeAgoLocale[index](); - return cache[index]; - }; - }; - - timeago.register(timeagoLanguageCode, memoizedLocale()); - timeago.register(`${timeagoLanguageCode}-remaining`, memoizedLocaleRemaining()); - - timeagoInstance = timeago(); - } +const memoizedLocaleRemaining = () => { + const cache = []; + + const timeAgoLocaleRemaining = [ + () => [s__('Timeago|just now'), s__('Timeago|right now')], + () => [s__('Timeago|just now'), s__('Timeago|%s seconds remaining')], + () => [s__('Timeago|1 minute ago'), s__('Timeago|1 minute remaining')], + () => [s__('Timeago|%s minutes ago'), s__('Timeago|%s minutes remaining')], + () => [s__('Timeago|1 hour ago'), s__('Timeago|1 hour remaining')], + () => [s__('Timeago|%s hours ago'), s__('Timeago|%s hours remaining')], + () => [s__('Timeago|1 day ago'), s__('Timeago|1 day remaining')], + () => [s__('Timeago|%s days ago'), s__('Timeago|%s days remaining')], + () => [s__('Timeago|1 week ago'), s__('Timeago|1 week remaining')], + () => [s__('Timeago|%s weeks ago'), s__('Timeago|%s weeks remaining')], + () => [s__('Timeago|1 month ago'), s__('Timeago|1 month remaining')], + () => [s__('Timeago|%s months ago'), s__('Timeago|%s months remaining')], + () => [s__('Timeago|1 year ago'), s__('Timeago|1 year remaining')], + () => [s__('Timeago|%s years ago'), s__('Timeago|%s years remaining')], + ]; + + return (number, index) => { + if (cache[index]) { + return cache[index]; + } + cache[index] = timeAgoLocaleRemaining[index] && timeAgoLocaleRemaining[index](); + return cache[index]; + }; +}; + +const memoizedLocale = () => { + const cache = []; + + const timeAgoLocale = [ + () => [s__('Timeago|just now'), s__('Timeago|right now')], + () => [s__('Timeago|just now'), s__('Timeago|in %s seconds')], + () => [s__('Timeago|1 minute ago'), s__('Timeago|in 1 minute')], + () => [s__('Timeago|%s minutes ago'), s__('Timeago|in %s minutes')], + () => [s__('Timeago|1 hour ago'), s__('Timeago|in 1 hour')], + () => [s__('Timeago|%s hours ago'), s__('Timeago|in %s hours')], + () => [s__('Timeago|1 day ago'), s__('Timeago|in 1 day')], + () => [s__('Timeago|%s days ago'), s__('Timeago|in %s days')], + () => [s__('Timeago|1 week ago'), s__('Timeago|in 1 week')], + () => [s__('Timeago|%s weeks ago'), s__('Timeago|in %s weeks')], + () => [s__('Timeago|1 month ago'), s__('Timeago|in 1 month')], + () => [s__('Timeago|%s months ago'), s__('Timeago|in %s months')], + () => [s__('Timeago|1 year ago'), s__('Timeago|in 1 year')], + () => [s__('Timeago|%s years ago'), s__('Timeago|in %s years')], + ]; - return timeagoInstance; + return (number, index) => { + if (cache[index]) { + return cache[index]; + } + cache[index] = timeAgoLocale[index] && timeAgoLocale[index](); + return cache[index]; + }; }; +timeago.register(timeagoLanguageCode, memoizedLocale()); +timeago.register(`${timeagoLanguageCode}-remaining`, memoizedLocaleRemaining()); + +export const getTimeago = () => timeago; + /** * For the given elements, sets a tooltip with a formatted date. * @param {JQuery} $timeagoEls * @param {Boolean} setTimeago */ export const localTimeAgo = ($timeagoEls, setTimeago = true) => { - getTimeago(); - $timeagoEls.each((i, el) => { - $(el).text(timeagoInstance.format($(el).attr('datetime'), timeagoLanguageCode)); + $(el).text(timeago.format($(el).attr('datetime'), timeagoLanguageCode)); }); if (!setTimeago) { @@ -207,9 +197,7 @@ export const timeFor = (time, expiredLabel) => { if (new Date(time) < new Date()) { return expiredLabel || s__('Timeago|Past due'); } - return getTimeago() - .format(time, `${timeagoLanguageCode}-remaining`) - .trim(); + return timeago.format(time, `${timeagoLanguageCode}-remaining`).trim(); }; export const getDayDifference = (a, b) => { @@ -459,7 +447,7 @@ export const parsePikadayDate = dateString => { /** * Used `onSelect` method in pickaday * @param {Date} date UTC format - * @return {String} Date formated in yyyy-mm-dd + * @return {String} Date formatted in yyyy-mm-dd */ export const pikadayToString = date => { const day = pad(date.getDate()); @@ -525,8 +513,8 @@ export const stringifyTime = (timeObject, fullNameFormat = false) => { if (fullNameFormat && isNonZero) { // Remove traling 's' if unit value is singular - const formatedUnitName = unitValue > 1 ? unitName : unitName.replace(/s$/, ''); - return `${memo} ${unitValue} ${formatedUnitName}`; + const formattedUnitName = unitValue > 1 ? unitName : unitName.replace(/s$/, ''); + return `${memo} ${unitValue} ${formattedUnitName}`; } return isNonZero ? `${memo} ${unitValue}${unitName.charAt(0)}` : memo; @@ -602,3 +590,19 @@ export const getDatesInRange = (d1, d2, formatter = x => x) => { * @return {Number} number of milliseconds */ export const secondsToMilliseconds = seconds => seconds * 1000; + +/** + * Converts the supplied number of seconds to days. + * + * @param {Number} seconds + * @return {Number} number of days + */ +export const secondsToDays = seconds => Math.round(seconds / 86400); + +/** + * Returns the date after the date provided + * + * @param {Date} date the initial date + * @return {Date} the date following the date provided + */ +export const dayAfter = date => new Date(newDate(date).setDate(date.getDate() + 1)); diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js index 5e5d10883a3..1c7d59054dc 100644 --- a/app/assets/javascripts/lib/utils/http_status.js +++ b/app/assets/javascripts/lib/utils/http_status.js @@ -21,6 +21,7 @@ const httpStatusCodes = { NOT_FOUND: 404, GONE: 410, UNPROCESSABLE_ENTITY: 422, + SERVICE_UNAVAILABLE: 503, }; export const successCodes = [ diff --git a/app/assets/javascripts/lib/utils/logoutput_behaviours.js b/app/assets/javascripts/lib/utils/logoutput_behaviours.js deleted file mode 100644 index 41b57025cc9..00000000000 --- a/app/assets/javascripts/lib/utils/logoutput_behaviours.js +++ /dev/null @@ -1,47 +0,0 @@ -import $ from 'jquery'; -import { - canScroll, - isScrolledToBottom, - isScrolledToTop, - isScrolledToMiddle, - toggleDisableButton, -} from './scroll_utils'; - -export default class LogOutputBehaviours { - constructor() { - // Scroll buttons - this.$scrollTopBtn = $('.js-scroll-up'); - this.$scrollBottomBtn = $('.js-scroll-down'); - - this.$scrollTopBtn.off('click').on('click', this.scrollToTop.bind(this)); - this.$scrollBottomBtn.off('click').on('click', this.scrollToBottom.bind(this)); - } - - toggleScroll() { - if (canScroll()) { - if (isScrolledToMiddle()) { - // User is in the middle of the log - - toggleDisableButton(this.$scrollTopBtn, false); - toggleDisableButton(this.$scrollBottomBtn, false); - } else if (isScrolledToTop()) { - // User is at Top of Log - - toggleDisableButton(this.$scrollTopBtn, true); - toggleDisableButton(this.$scrollBottomBtn, false); - } else if (isScrolledToBottom()) { - // User is at the bottom of the build log. - - toggleDisableButton(this.$scrollTopBtn, false); - toggleDisableButton(this.$scrollBottomBtn, true); - } - } else { - toggleDisableButton(this.$scrollTopBtn, true); - toggleDisableButton(this.$scrollBottomBtn, true); - } - } - - toggleScrollAnimation(toggle) { - this.$scrollBottomBtn.toggleClass('animate', toggle); - } -} diff --git a/app/assets/javascripts/lib/utils/suppress_ajax_errors_during_navigation.js b/app/assets/javascripts/lib/utils/suppress_ajax_errors_during_navigation.js index 4c61da9b862..fb4d9b7de9c 100644 --- a/app/assets/javascripts/lib/utils/suppress_ajax_errors_during_navigation.js +++ b/app/assets/javascripts/lib/utils/suppress_ajax_errors_during_navigation.js @@ -2,8 +2,8 @@ * An Axios error interceptor that suppresses AJAX errors caused * by the request being cancelled when the user navigates to a new page */ -export default (err, isUserNavigating, featureFlagEnabled) => { - if (featureFlagEnabled && isUserNavigating && err.code === 'ECONNABORTED') { +export default (err, isUserNavigating) => { + if (isUserNavigating && err.code === 'ECONNABORTED') { // If the user is navigating away from the current page, // prevent .then() and .catch() handlers from being // called by returning a Promise that never resolves diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 2e0270ee42f..cccf9ad311c 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, no-param-reassign, one-var, operator-assignment, no-else-return, consistent-return */ +/* eslint-disable func-names, no-param-reassign, operator-assignment, no-else-return, consistent-return */ import $ from 'jquery'; import { insertText } from '~/lib/utils/common_utils'; @@ -13,8 +13,7 @@ function addBlockTags(blockTag, selected) { } function lineBefore(text, textarea) { - var split; - split = text + const split = text .substring(0, textarea.selectionStart) .trim() .split('\n'); @@ -80,7 +79,7 @@ function moveCursor({ editorSelectionStart, editorSelectionEnd, }) { - var pos; + let pos; if (textArea && !textArea.setSelectionRange) { return; } @@ -132,18 +131,13 @@ export function insertMarkdownText({ select, editor, }) { - var textToInsert, - selectedSplit, - startChar, - removedLastNewLine, - removedFirstNewLine, - currentLineEmpty, - lastNewLine, - editorSelectionStart, - editorSelectionEnd; - removedLastNewLine = false; - removedFirstNewLine = false; - currentLineEmpty = false; + let removedLastNewLine = false; + let removedFirstNewLine = false; + let currentLineEmpty = false; + let editorSelectionStart; + let editorSelectionEnd; + let lastNewLine; + let textToInsert; if (editor) { const selectionRange = editor.getSelectionRange(); @@ -186,7 +180,7 @@ export function insertMarkdownText({ } } - selectedSplit = selected.split('\n'); + const selectedSplit = selected.split('\n'); if (editor && !wrap) { lastNewLine = editor.getValue().split('\n')[editorSelectionStart.row]; @@ -207,8 +201,7 @@ export function insertMarkdownText({ (textArea && textArea.selectionStart === 0) || (editor && editorSelectionStart.column === 0 && editorSelectionStart.row === 0); - startChar = !wrap && !currentLineEmpty && !isBeginning ? '\n' : ''; - + const startChar = !wrap && !currentLineEmpty && !isBeginning ? '\n' : ''; const textPlaceholder = '{text}'; if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) { @@ -263,11 +256,10 @@ export function insertMarkdownText({ } function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) { - var $textArea, selected, text; - $textArea = $(textArea); + const $textArea = $(textArea); textArea = $textArea.get(0); - text = $textArea.val(); - selected = selectedText(text, textArea) || tagContent; + const text = $textArea.val(); + const selected = selectedText(text, textArea) || tagContent; $textArea.focus(); return insertMarkdownText({ textArea, diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 0c194d67bce..6bbf118d7d1 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -72,7 +72,7 @@ export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3 * @param {String} sha * @returns {String} */ -export const truncateSha = sha => sha.substr(0, 8); +export const truncateSha = sha => sha.substring(0, 8); const ELLIPSIS_CHAR = '…'; export const truncatePathMiddleToLength = (text, maxWidth) => { diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 4be0d05a9b7..d48678c21f6 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -1,4 +1,6 @@ -import { join as joinPaths } from 'path'; +const PATH_SEPARATOR = '/'; +const PATH_SEPARATOR_LEADING_REGEX = new RegExp(`^${PATH_SEPARATOR}+`); +const PATH_SEPARATOR_ENDING_REGEX = new RegExp(`${PATH_SEPARATOR}+$`); // Returns a decoded url parameter value // - Treats '+' as '%20' @@ -6,6 +8,37 @@ function decodeUrlParameter(val) { return decodeURIComponent(val.replace(/\+/g, '%20')); } +function cleanLeadingSeparator(path) { + return path.replace(PATH_SEPARATOR_LEADING_REGEX, ''); +} + +function cleanEndingSeparator(path) { + return path.replace(PATH_SEPARATOR_ENDING_REGEX, ''); +} + +/** + * Safely joins the given paths which might both start and end with a `/` + * + * Example: + * - `joinPaths('abc/', '/def') === 'abc/def'` + * - `joinPaths(null, 'abc/def', 'zoo) === 'abc/def/zoo'` + * + * @param {...String} paths + * @returns {String} + */ +export function joinPaths(...paths) { + return paths.reduce((acc, path) => { + if (!path) { + return acc; + } + if (!acc) { + return path; + } + + return [cleanEndingSeparator(acc), PATH_SEPARATOR, cleanLeadingSeparator(path)].join(''); + }, ''); +} + // Returns an array containing the value(s) of the // of the key passed as an argument export function getParameterValues(sParam, url = window.location) { @@ -181,4 +214,71 @@ export function getWebSocketUrl(path) { return `${getWebSocketProtocol()}//${joinPaths(window.location.host, path)}`; } -export { joinPaths }; +/** + * Convert search query into an object + * + * @param {String} query from "document.location.search" + * @returns {Object} + * + * ex: "?one=1&two=2" into {one: 1, two: 2} + */ +export function queryToObject(query) { + const removeQuestionMarkFromQuery = String(query).startsWith('?') ? query.slice(1) : query; + return removeQuestionMarkFromQuery.split('&').reduce((accumulator, curr) => { + const p = curr.split('='); + accumulator[decodeURIComponent(p[0])] = decodeURIComponent(p[1]); + return accumulator; + }, {}); +} + +/** + * Convert search query object back into a search query + * + * @param {Object} obj that needs to be converted + * @returns {String} + * + * ex: {one: 1, two: 2} into "one=1&two=2" + * + */ +export function objectToQuery(obj) { + return Object.keys(obj) + .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(obj[k])}`) + .join('&'); +} + +/** + * Sets query params for a given URL + * It adds new query params, updates existing params with a new value and removes params with value null/undefined + * + * @param {Object} params The query params to be set/updated + * @param {String} url The url to be operated on + * @param {Boolean} clearParams Indicates whether existing query params should be removed or not + * @returns {String} A copy of the original with the updated query params + */ +export const setUrlParams = (params, url = window.location.href, clearParams = false) => { + const urlObj = new URL(url); + const queryString = urlObj.search; + const searchParams = clearParams ? new URLSearchParams('') : new URLSearchParams(queryString); + + Object.keys(params).forEach(key => { + if (params[key] === null || params[key] === undefined) { + searchParams.delete(key); + } else if (Array.isArray(params[key])) { + params[key].forEach((val, idx) => { + if (idx === 0) { + searchParams.set(key, val); + } else { + searchParams.append(key, val); + } + }); + } else { + searchParams.set(key, params[key]); + } + }); + + urlObj.search = searchParams.toString(); + + return urlObj.toString(); +}; + +export const escapeFileUrl = fileUrl => encodeURIComponent(fileUrl).replace(/%2F/g, '/'); diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 465c9a362ba..674415c9d01 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -222,6 +222,7 @@ document.addEventListener('DOMContentLoaded', () => { } }); + // eslint-disable-next-line no-jquery/no-ajax-events $(document).ajaxError((e, xhrObj) => { const ref = xhrObj.status; diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue index 8eeac737a11..1df7ca37a98 100644 --- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue +++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue @@ -29,7 +29,7 @@ const AREA_COLOR_RGBA = `rgba(${hexToRgb(AREA_COLOR).join(',')},${AREA_OPACITY}) * time series chart, the boundary band shows the normal * range of values the metric should take. * - * This component accepts 3 queries, which contain the + * This component accepts 3 metrics, which contain the * "metric", "upper" limit and "lower" limit. * * The upper and lower series are "stacked areas" visually @@ -62,10 +62,11 @@ export default { }, computed: { series() { - return this.graphData.queries.map(query => { - const values = query.result[0] ? query.result[0].values : []; + return this.graphData.metrics.map(metric => { + const values = metric.result && metric.result[0] ? metric.result[0].values : []; return { - label: query.label, + label: metric.label, + // NaN values may disrupt avg., max. & min. calculations in the legend, filter them out data: values.filter(([, value]) => !Number.isNaN(value)), }; }); @@ -82,7 +83,7 @@ export default { return min < 0 ? -min : 0; }, metricData() { - const originalMetricQuery = this.graphData.queries[0]; + const originalMetricQuery = this.graphData.metrics[0]; const metricQuery = { ...originalMetricQuery }; metricQuery.result[0].values = metricQuery.result[0].values.map(([x, y]) => [ @@ -92,7 +93,7 @@ export default { return { ...this.graphData, type: 'line-chart', - queries: [metricQuery], + metrics: [metricQuery], }; }, metricSeriesConfig() { diff --git a/app/assets/javascripts/monitoring/components/charts/column.vue b/app/assets/javascripts/monitoring/components/charts/column.vue index ee6aaeb7dde..eb407ad1d7f 100644 --- a/app/assets/javascripts/monitoring/components/charts/column.vue +++ b/app/assets/javascripts/monitoring/components/charts/column.vue @@ -32,8 +32,8 @@ export default { }, computed: { chartData() { - const queryData = this.graphData.queries.reduce((acc, query) => { - const series = makeDataSeries(query.result, { + const queryData = this.graphData.metrics.reduce((acc, query) => { + const series = makeDataSeries(query.result || [], { name: this.formatLegendLabel(query), }); @@ -45,13 +45,13 @@ export default { }; }, xAxisTitle() { - return this.graphData.queries[0].result[0].x_label !== undefined - ? this.graphData.queries[0].result[0].x_label + return this.graphData.metrics[0].result[0].x_label !== undefined + ? this.graphData.metrics[0].result[0].x_label : ''; }, yAxisTitle() { - return this.graphData.queries[0].result[0].y_label !== undefined - ? this.graphData.queries[0].result[0].y_label + return this.graphData.metrics[0].result[0].y_label !== undefined + ? this.graphData.metrics[0].result[0].y_label : ''; }, xAxisType() { diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue index b8158247e49..6ab5aaeba1d 100644 --- a/app/assets/javascripts/monitoring/components/charts/heatmap.vue +++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue @@ -24,7 +24,7 @@ export default { }, computed: { chartData() { - return this.queries.result.reduce( + return this.metrics.result.reduce( (acc, result, i) => [...acc, ...result.values.map((value, j) => [i, j, value[1]])], [], ); @@ -36,7 +36,7 @@ export default { return this.graphData.y_label || ''; }, xAxisLabels() { - return this.queries.result.map(res => Object.values(res.metric)[0]); + return this.metrics.result.map(res => Object.values(res.metric)[0]); }, yAxisLabels() { return this.result.values.map(val => { @@ -46,10 +46,10 @@ export default { }); }, result() { - return this.queries.result[0]; + return this.metrics.result[0]; }, - queries() { - return this.graphData.queries[0]; + metrics() { + return this.graphData.metrics[0]; }, }, }; diff --git a/app/assets/javascripts/monitoring/components/charts/single_stat.vue b/app/assets/javascripts/monitoring/components/charts/single_stat.vue index 076682820e6..e75ddb05808 100644 --- a/app/assets/javascripts/monitoring/components/charts/single_stat.vue +++ b/app/assets/javascripts/monitoring/components/charts/single_stat.vue @@ -17,7 +17,7 @@ export default { }, computed: { queryInfo() { - return this.graphData.queries[0]; + return this.graphData.metrics[0]; }, engineeringNotation() { return `${roundOffFloat(this.queryInfo.result[0].value[1], 1)}${this.queryInfo.unit}`; diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index 6a88c8a5ee3..0d442f14aea 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -1,9 +1,9 @@ <script> -import { s__, __ } from '~/locale'; import _ from 'underscore'; import { GlLink, GlButton, GlTooltip, GlResizeObserverDirective } from '@gitlab/ui'; import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import dateFormat from 'dateformat'; +import { s__, __ } from '~/locale'; import { roundOffFloat } from '~/lib/utils/common_utils'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import Icon from '~/vue_shared/components/icon.vue'; @@ -105,7 +105,7 @@ export default { // Transforms & supplements query data to render appropriate labels & styles // Input: [{ queryAttributes1 }, { queryAttributes2 }] // Output: [{ seriesAttributes1 }, { seriesAttributes2 }] - return this.graphData.queries.reduce((acc, query) => { + return this.graphData.metrics.reduce((acc, query) => { const { appearance } = query; const lineType = appearance && appearance.line && appearance.line.type @@ -121,7 +121,7 @@ export default { ? appearance.area.opacity : undefined, }; - const series = makeDataSeries(query.result, { + const series = makeDataSeries(query.result || [], { name: this.formatLegendLabel(query), lineStyle: { type: lineType, @@ -253,23 +253,25 @@ export default { 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, dataIndex } = dataPoint; - const value = yVal.toFixed(3); - this.tooltip.content.push({ - name: seriesName, - dataIndex, - value, - color, - }); + if (dataPoint.value) { + 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, dataIndex } = dataPoint; + const value = yVal.toFixed(3); + this.tooltip.content.push({ + name: seriesName, + dataIndex, + value, + color, + }); + } } }); }, diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 26e2c2568c1..c1ca5449ba3 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -1,6 +1,6 @@ <script> import _ from 'underscore'; -import { mapActions, mapState } from 'vuex'; +import { mapActions, mapState, mapGetters } from 'vuex'; import VueDraggable from 'vuedraggable'; import { GlButton, @@ -11,28 +11,27 @@ import { GlModalDirective, GlTooltipDirective, } from '@gitlab/ui'; +import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; import { s__ } from '~/locale'; import createFlash from '~/flash'; import Icon from '~/vue_shared/components/icon.vue'; import { getParameterValues, mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; import invalidUrl from '~/lib/utils/invalid_url'; -import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; import DateTimePicker from './date_time_picker/date_time_picker.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 GroupEmptyState from './group_empty_state.vue'; import TrackEventDirective from '~/vue_shared/directives/track_event'; -import { getTimeDiff, isValidDate, downloadCSVOptions, generateLinkToChartOptions } from '../utils'; +import { getTimeDiff, isValidDate, getAddMetricTrackingOptions } from '../utils'; +import { metricStates } from '../constants'; export default { components: { VueDraggable, - MonitorTimeSeriesChart, - MonitorSingleStatChart, PanelType, GraphGroup, EmptyState, + GroupEmptyState, Icon, GlButton, GlDropdown, @@ -103,6 +102,10 @@ export default { type: String, required: true, }, + emptyNoDataSmallSvgPath: { + type: String, + required: true, + }, emptyUnableToConnectSvgPath: { type: String, required: true, @@ -180,11 +183,11 @@ export default { 'showEmptyState', 'environments', 'deploymentData', - 'metricsWithData', 'useDashboardEndpoint', 'allDashboards', 'additionalPanelTypesEnabled', ]), + ...mapGetters('monitoringDashboard', ['getMetricStates']), firstDashboard() { return this.environmentsEndpoint.length > 0 && this.allDashboards.length > 0 ? this.allDashboards[0] @@ -252,28 +255,18 @@ export default { 'setEndpoints', 'setPanelGroupMetrics', ]), - chartsWithData(charts) { - return charts.filter(chart => - chart.metrics.some(metric => this.metricsWithData.includes(metric.metric_id)), - ); - }, - updateMetrics(key, metrics) { + updatePanels(key, panels) { this.setPanelGroupMetrics({ - metrics, + panels, key, }); }, - removeMetric(key, metrics, graphIndex) { + removePanel(key, panels, graphIndex) { this.setPanelGroupMetrics({ - metrics: metrics.filter((v, i) => i !== graphIndex), + panels: panels.filter((v, i) => i !== graphIndex), key, }); }, - removeGraph(metrics, graphIndex) { - // At present graphs will not be removed, they should removed using the vuex store - // See https://gitlab.com/gitlab-org/gitlab/issues/27835 - metrics.splice(graphIndex, 1); - }, showInvalidDateError() { createFlash(s__('Metrics|Link contains an invalid time window.')); }, @@ -294,14 +287,36 @@ export default { submitCustomMetricsForm() { this.$refs.customMetricsForm.submit(); }, - groupHasData(group) { - return this.chartsWithData(group.metrics).length > 0; - }, onDateTimePickerApply(timeWindowUrlParams) { return redirectTo(mergeUrlParams(timeWindowUrlParams, window.location.href)); }, - downloadCSVOptions, - generateLinkToChartOptions, + /** + * Return a single empty state for a group. + * + * If all states are the same a single state is returned to be displayed + * Except if the state is OK, in which case the group is displayed. + * + * @param {String} groupKey - Identifier for group + * @returns {String} state code from `metricStates` + */ + groupSingleEmptyState(groupKey) { + const states = this.getMetricStates(groupKey); + if (states.length === 1 && states[0] !== metricStates.OK) { + return states[0]; + } + return null; + }, + /** + * A group should be not collapsed if any metric is loaded (OK) + * + * @param {String} groupKey - Identifier for group + * @returns {Boolean} If the group should be collapsed + */ + collapseGroup(groupKey) { + // Collapse group if no data is available + return !this.getMetricStates(groupKey).includes(metricStates.OK); + }, + getAddMetricTrackingOptions, }, addMetric: { title: s__('Metrics|Add metric'), @@ -393,9 +408,10 @@ export default { </gl-button> <gl-button v-if="addingMetricsAvailable" + ref="addMetricBtn" v-gl-modal="$options.addMetric.modalId" variant="outline-success" - class="mr-2 mt-1 js-add-metric-button" + class="mr-2 mt-1" > {{ $options.addMetric.title }} </gl-button> @@ -415,6 +431,8 @@ export default { <div slot="modal-footer"> <gl-button @click="hideAddMetricModal">{{ __('Cancel') }}</gl-button> <gl-button + ref="submitCustomMetricsFormBtn" + v-track-event="getAddMetricTrackingOptions()" :disabled="!formIsValid" variant="success" @click="submitCustomMetricsForm" @@ -454,42 +472,55 @@ export default { :key="`${groupData.group}.${groupData.priority}`" :name="groupData.group" :show-panels="showPanels" - :collapse-group="groupHasData(groupData)" + :collapse-group="collapseGroup(groupData.key)" > - <vue-draggable - :value="groupData.metrics" - group="metrics-dashboard" - :component-data="{ attrs: { class: 'row mx-0 w-100' } }" - :disabled="!isRearrangingPanels" - @input="updateMetrics(groupData.key, $event)" - > - <div - v-for="(graphData, graphIndex) in groupData.metrics" - :key="`panel-type-${graphIndex}`" - class="col-12 col-lg-6 px-2 mb-2 draggable" - :class="{ 'draggable-enabled': isRearrangingPanels }" + <div v-if="!groupSingleEmptyState(groupData.key)"> + <vue-draggable + :value="groupData.panels" + group="metrics-dashboard" + :component-data="{ attrs: { class: 'row mx-0 w-100' } }" + :disabled="!isRearrangingPanels" + @input="updatePanels(groupData.key, $event)" > - <div class="position-relative draggable-panel js-draggable-panel"> - <div - v-if="isRearrangingPanels" - class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end" - @click="removeGraph(groupData.metrics, graphIndex)" - > - <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')" - ><icon name="close" - /></a> - </div> + <div + v-for="(graphData, graphIndex) in groupData.panels" + :key="`panel-type-${graphIndex}`" + class="col-12 col-lg-6 px-2 mb-2 draggable" + :class="{ 'draggable-enabled': isRearrangingPanels }" + > + <div class="position-relative draggable-panel js-draggable-panel"> + <div + v-if="isRearrangingPanels" + class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end" + @click="removePanel(groupData.key, groupData.panels, graphIndex)" + > + <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')" + ><icon name="close" + /></a> + </div> - <panel-type - :clipboard-text="generateLink(groupData.group, graphData.title, graphData.y_label)" - :graph-data="graphData" - :alerts-endpoint="alertsEndpoint" - :prometheus-alerts-available="prometheusAlertsAvailable" - :index="`${index}-${graphIndex}`" - /> + <panel-type + :clipboard-text=" + generateLink(groupData.group, graphData.title, graphData.y_label) + " + :graph-data="graphData" + :alerts-endpoint="alertsEndpoint" + :prometheus-alerts-available="prometheusAlertsAvailable" + :index="`${index}-${graphIndex}`" + /> + </div> </div> - </div> - </vue-draggable> + </vue-draggable> + </div> + <div v-else class="py-5 col col-sm-10 col-md-8 col-lg-7 col-xl-6"> + <group-empty-state + ref="empty-group" + :documentation-path="documentationPath" + :settings-path="settingsPath" + :selected-state="groupSingleEmptyState(groupData.key)" + :svg-path="emptyNoDataSmallSvgPath" + /> + </div> </graph-group> </div> <empty-state @@ -501,6 +532,7 @@ export default { :empty-getting-started-svg-path="emptyGettingStartedSvgPath" :empty-loading-svg-path="emptyLoadingSvgPath" :empty-no-data-svg-path="emptyNoDataSvgPath" + :empty-no-data-small-svg-path="emptyNoDataSmallSvgPath" :empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath" :compact="smallEmptyState" /> diff --git a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue index 0388a6190d9..c3beae18726 100644 --- a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue +++ b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue @@ -1,7 +1,7 @@ <script> import _ from 'underscore'; -import { s__, sprintf } from '~/locale'; import { GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; import { dateFormats } from '~/monitoring/constants'; const inputGroupText = { diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue index f75839c7c6b..eb8945c1a57 100644 --- a/app/assets/javascripts/monitoring/components/embed.vue +++ b/app/assets/javascripts/monitoring/components/embed.vue @@ -1,8 +1,8 @@ <script> -import { mapActions, mapState } from 'vuex'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; 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'; @@ -11,7 +11,7 @@ let sidebarMutationObserver; export default { components: { GraphGroup, - MonitorTimeSeriesChart, + PanelType, }, props: { dashboardUrl: { @@ -35,13 +35,17 @@ export default { }; }, computed: { - ...mapState('monitoringDashboard', ['dashboard', 'metricsWithData']), + ...mapState('monitoringDashboard', ['dashboard']), + ...mapGetters('monitoringDashboard', ['metricsWithData']), charts() { + if (!this.dashboard || !this.dashboard.panel_groups) { + return []; + } const groupWithMetrics = this.dashboard.panel_groups.find(group => - group.metrics.find(chart => this.chartHasData(chart)), - ) || { metrics: [] }; + group.panels.find(chart => this.chartHasData(chart)), + ) || { panels: [] }; - return groupWithMetrics.metrics.filter(chart => this.chartHasData(chart)); + return groupWithMetrics.panels.filter(chart => this.chartHasData(chart)); }, isSingleChart() { return this.charts.length === 1; @@ -70,7 +74,7 @@ export default { 'setShowErrorBanner', ]), chartHasData(chart) { - return chart.metrics.some(metric => this.metricsWithData.includes(metric.metric_id)); + return chart.metrics.some(metric => this.metricsWithData().includes(metric.metric_id)); }, onSidebarMutation() { setTimeout(() => { @@ -89,16 +93,12 @@ export default { <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" + <panel-type + v-for="(graphData, graphIndex) in charts" + :key="`panel-type-${graphIndex}`" class="w-100" :graph-data="graphData" - :container-width="elWidth" :group-id="dashboardUrl" - :project-path="null" - :show-border="true" - :single-embed="isSingleChart" /> </div> </div> diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue index 1bb40447a3e..d3157b731b2 100644 --- a/app/assets/javascripts/monitoring/components/empty_state.vue +++ b/app/assets/javascripts/monitoring/components/empty_state.vue @@ -1,6 +1,6 @@ <script> -import { __ } from '~/locale'; import { GlEmptyState } from '@gitlab/ui'; +import { __ } from '~/locale'; export default { components: { @@ -37,6 +37,10 @@ export default { type: String, required: true, }, + emptyNoDataSmallSvgPath: { + type: String, + required: true, + }, emptyUnableToConnectSvgPath: { type: String, required: true, diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue index 3cb6ccb64b1..5a7981b6534 100644 --- a/app/assets/javascripts/monitoring/components/graph_group.vue +++ b/app/assets/javascripts/monitoring/components/graph_group.vue @@ -15,34 +15,44 @@ export default { required: false, default: true, }, + /** + * Initial value of collapse on mount. + */ collapseGroup: { type: Boolean, - required: true, + required: false, + default: false, }, }, data() { return { - showGroup: true, + isCollapsed: this.collapseGroup, }; }, computed: { caretIcon() { - return this.collapseGroup && this.showGroup ? 'angle-down' : 'angle-right'; + return this.isCollapsed ? 'angle-right' : 'angle-down'; }, }, - created() { - this.showGroup = this.collapseGroup; + watch: { + collapseGroup(val) { + // Respond to changes in collapseGroup but do not + // collapse it once was opened by the user. + if (this.showPanels && !val) { + this.isCollapsed = false; + } + }, }, methods: { collapse() { - this.showGroup = !this.showGroup; + this.isCollapsed = !this.isCollapsed; }, }, }; </script> <template> - <div v-if="showPanels" class="card prometheus-panel"> + <div v-if="showPanels" ref="graph-group" class="card prometheus-panel"> <div class="card-header d-flex align-items-center"> <h4 class="flex-grow-1">{{ name }}</h4> <a role="button" class="js-graph-group-toggle" @click="collapse"> @@ -50,12 +60,12 @@ export default { </a> </div> <div - v-if="collapseGroup" - v-show="collapseGroup && showGroup" + v-show="!isCollapsed" + ref="graph-group-content" class="card-body prometheus-graph-group p-0" > <slot></slot> </div> </div> - <div v-else class="prometheus-graph-group"><slot></slot></div> + <div v-else ref="graph-group-content" class="prometheus-graph-group"><slot></slot></div> </template> diff --git a/app/assets/javascripts/monitoring/components/group_empty_state.vue b/app/assets/javascripts/monitoring/components/group_empty_state.vue new file mode 100644 index 00000000000..dee4e5998ee --- /dev/null +++ b/app/assets/javascripts/monitoring/components/group_empty_state.vue @@ -0,0 +1,105 @@ +<script> +import { __, sprintf } from '~/locale'; +import { GlEmptyState } from '@gitlab/ui'; +import { metricStates } from '../constants'; + +export default { + components: { + GlEmptyState, + }, + props: { + documentationPath: { + type: String, + required: true, + }, + settingsPath: { + type: String, + required: true, + }, + selectedState: { + type: String, + required: true, + }, + svgPath: { + type: String, + required: true, + }, + }, + data() { + const documentationLink = `<a href="${this.documentationPath}">${__('More information')}</a>`; + return { + states: { + [metricStates.NO_DATA]: { + title: __('No data to display'), + slottedDescription: sprintf( + __( + 'The data source is connected, but there is no data to display. %{documentationLink}', + ), + { documentationLink }, + false, + ), + }, + [metricStates.TIMEOUT]: { + title: __('Connection timed out'), + slottedDescription: sprintf( + __( + "Charts can't be displayed as the request for data has timed out. %{documentationLink}", + ), + { documentationLink }, + false, + ), + }, + [metricStates.CONNECTION_FAILED]: { + title: __('Connection failed'), + description: __(`We couldn't reach the Prometheus server. + Either the server no longer exists or the configuration details need updating.`), + buttonText: __('Verify configuration'), + buttonPath: this.settingsPath, + }, + [metricStates.BAD_QUERY]: { + title: __('Query cannot be processed'), + slottedDescription: sprintf( + __( + `The Prometheus server responded with "bad request". + Please check your queries are correct and are supported in your Prometheus version. %{documentationLink}`, + ), + { documentationLink }, + false, + ), + buttonText: __('Verify configuration'), + buttonPath: this.settingsPath, + }, + [metricStates.LOADING]: { + title: __('Waiting for performance data'), + description: __(`Creating graphs uses the data from the Prometheus server. + If this takes a long time, ensure that data is available.`), + }, + [metricStates.UNKNOWN_ERROR]: { + title: __('An error has occurred'), + description: __('An error occurred while loading the data. Please try again.'), + }, + }, + }; + }, + computed: { + currentState() { + return this.states[this.selectedState] || this.states[metricStates.UNKNOWN_ERROR]; + }, + }, +}; +</script> + +<template> + <gl-empty-state + :title="currentState.title" + :primary-button-text="currentState.buttonText" + :primary-button-link="currentState.buttonPath" + :description="currentState.description" + :svg-path="svgPath" + :compact="true" + > + <template v-if="currentState.slottedDescription" #description> + <div v-html="currentState.slottedDescription"></div> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue index cafb4b0b479..ec6a41d0540 100644 --- a/app/assets/javascripts/monitoring/components/panel_type.vue +++ b/app/assets/javascripts/monitoring/components/panel_type.vue @@ -1,7 +1,6 @@ <script> import { mapState } from 'vuex'; import _ from 'underscore'; -import { __ } from '~/locale'; import { GlDropdown, GlDropdownItem, @@ -9,6 +8,7 @@ import { GlModalDirective, GlTooltipDirective, } from '@gitlab/ui'; +import { __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import MonitorTimeSeriesChart from './charts/time_series.vue'; import MonitorAnomalyChart from './charts/anomaly.vue'; @@ -36,7 +36,8 @@ export default { props: { clipboardText: { type: String, - required: true, + required: false, + default: '', }, graphData: { type: Object, @@ -47,6 +48,11 @@ export default { required: false, default: '', }, + groupId: { + type: String, + required: false, + default: 'panel-type-chart', + }, }, computed: { ...mapState('monitoringDashboard', ['deploymentData', 'projectPath']), @@ -54,10 +60,14 @@ export default { return IS_EE && this.prometheusAlertsAvailable && this.alertsEndpoint && this.graphData; }, graphDataHasMetrics() { - return this.graphData.queries[0].result.length > 0; + return ( + this.graphData.metrics && + this.graphData.metrics[0].result && + this.graphData.metrics[0].result.length > 0 + ); }, csvText() { - const chartData = this.graphData.queries[0].result[0].values; + const chartData = this.graphData.metrics[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) => { @@ -112,21 +122,21 @@ export default { :graph-data="graphData" :deployment-data="deploymentData" :project-path="projectPath" - :thresholds="getGraphAlertValues(graphData.queries)" - group-id="panel-type-chart" + :thresholds="getGraphAlertValues(graphData.metrics)" + :group-id="groupId" > <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)" + :relevant-queries="graphData.metrics" + :alerts-to-manage="getGraphAlerts(graphData.metrics)" @setAlerts="setAlerts" /> <gl-dropdown v-gl-tooltip - class="mx-2" + class="ml-auto mx-3" toggle-class="btn btn-transparent border-0" :right="true" :no-caret="true" @@ -143,6 +153,7 @@ export default { {{ __('Download CSV') }} </gl-dropdown-item> <gl-dropdown-item + v-if="clipboardText" v-track-event="generateLinkToChartOptions(clipboardText)" class="js-chart-link" :data-clipboard-text="clipboardText" diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index 1a1fcdd0e66..398b45b9012 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -1,5 +1,52 @@ import { __ } from '~/locale'; +export const PROMETHEUS_TIMEOUT = 120000; // TWO_MINUTES + +/** + * States and error states in Prometheus Queries (PromQL) for metrics + */ +export const metricStates = { + /** + * Metric data is available + */ + OK: 'OK', + + /** + * Metric data is being fetched + */ + LOADING: 'LOADING', + + /** + * Connection timed out to prometheus server + * the timeout is set to PROMETHEUS_TIMEOUT + * + */ + TIMEOUT: 'TIMEOUT', + + /** + * The prometheus server replies with an empty data set + */ + NO_DATA: 'NO_DATA', + + /** + * The prometheus server cannot be reached + */ + CONNECTION_FAILED: 'CONNECTION_FAILED', + + /** + * The prometheus server was reached but it cannot process + * the query. This can happen for several reasons: + * - PromQL syntax is incorrect + * - An operator is not supported + */ + BAD_QUERY: 'BAD_QUERY', + + /** + * No specific reason found for error + */ + UNKNOWN_ERROR: 'UNKNOWN_ERROR', +}; + export const sidebarAnimationDuration = 300; // milliseconds. export const chartHeight = 300; diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index a14145d480b..d296f5b7a66 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -1,8 +1,8 @@ import Vue from 'vue'; import { GlToast } from '@gitlab/ui'; +import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue'; 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); diff --git a/app/assets/javascripts/monitoring/monitoring_tracking_helper.js b/app/assets/javascripts/monitoring/monitoring_tracking_helper.js new file mode 100644 index 00000000000..5ae1eca10de --- /dev/null +++ b/app/assets/javascripts/monitoring/monitoring_tracking_helper.js @@ -0,0 +1,10 @@ +import Tracking from '~/tracking'; + +const trackDashboardLoad = ({ label, value }) => + Tracking.event(document.body.dataset.page, 'dashboard_fetch', { + label, + property: 'count', + value, + }); + +export default trackDashboardLoad; diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 6a8e3cc82f5..1cb82ce0083 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -1,30 +1,25 @@ import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; +import trackDashboardLoad from '../monitoring_tracking_helper'; import statusCodes from '../../lib/utils/http_status'; import { backOff } from '../../lib/utils/common_utils'; -import { s__, __ } from '../../locale'; +import { s__, sprintf } from '../../locale'; -const MAX_REQUESTS = 3; +import { PROMETHEUS_TIMEOUT } from '../constants'; -export function backOffRequest(makeRequestCallback) { - let requestCounter = 0; +function backOffRequest(makeRequestCallback) { return backOff((next, stop) => { makeRequestCallback() .then(resp => { if (resp.status === statusCodes.NO_CONTENT) { - requestCounter += 1; - if (requestCounter < MAX_REQUESTS) { - next(); - } else { - stop(new Error(__('Failed to connect to the prometheus server'))); - } + next(); } else { stop(resp); } }) .catch(stop); - }); + }, PROMETHEUS_TIMEOUT); } export const setGettingStartedEmptyState = ({ commit }) => { @@ -45,17 +40,12 @@ export const requestMetricsDashboard = ({ commit }) => { export const receiveMetricsDashboardSuccess = ({ commit, dispatch }, { response, params }) => { commit(types.SET_ALL_DASHBOARDS, response.all_dashboards); commit(types.RECEIVE_METRICS_DATA_SUCCESS, response.dashboard.panel_groups); - dispatch('fetchPrometheusMetrics', params); + return dispatch('fetchPrometheusMetrics', params); }; export const receiveMetricsDashboardFailure = ({ commit }, error) => { commit(types.RECEIVE_METRICS_DATA_FAILURE, error); }; -export const requestMetricsData = ({ commit }) => commit(types.REQUEST_METRICS_DATA); -export const receiveMetricsDataSuccess = ({ commit }, data) => - commit(types.RECEIVE_METRICS_DATA_SUCCESS, data); -export const receiveMetricsDataFailure = ({ commit }, error) => - commit(types.RECEIVE_METRICS_DATA_FAILURE, error); export const receiveDeploymentsDataSuccess = ({ commit }, data) => commit(types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS, data); export const receiveDeploymentsDataFailure = ({ commit }) => @@ -83,16 +73,22 @@ export const fetchDashboard = ({ state, dispatch }, params) => { return backOffRequest(() => axios.get(state.dashboardEndpoint, { params })) .then(resp => resp.data) - .then(response => { - dispatch('receiveMetricsDashboardSuccess', { - response, - params, - }); - }) - .catch(error => { - dispatch('receiveMetricsDashboardFailure', error); - if (state.setShowErrorBanner) { - createFlash(s__('Metrics|There was an error while retrieving metrics')); + .then(response => dispatch('receiveMetricsDashboardSuccess', { response, params })) + .catch(e => { + dispatch('receiveMetricsDashboardFailure', e); + if (state.showErrorBanner) { + if (e.response.data && e.response.data.message) { + const { message } = e.response.data; + createFlash( + sprintf( + s__('Metrics|There was an error while retrieving metrics. %{message}'), + { message }, + false, + ), + ); + } else { + createFlash(s__('Metrics|There was an error while retrieving metrics')); + } } }); }; @@ -129,12 +125,20 @@ export const fetchPrometheusMetric = ({ commit }, { metric, params }) => { step, }; - return fetchPrometheusResult(metric.prometheus_endpoint_path, queryParams).then(result => { - commit(types.SET_QUERY_RESULT, { metricId: metric.metric_id, result }); - }); + commit(types.REQUEST_METRIC_RESULT, { metricId: metric.metric_id }); + + return fetchPrometheusResult(metric.prometheus_endpoint_path, queryParams) + .then(result => { + commit(types.RECEIVE_METRIC_RESULT_SUCCESS, { metricId: metric.metric_id, result }); + }) + .catch(error => { + commit(types.RECEIVE_METRIC_RESULT_FAILURE, { metricId: metric.metric_id, error }); + // Continue to throw error so the dashboard can notify using createFlash + throw error; + }); }; -export const fetchPrometheusMetrics = ({ state, commit, dispatch }, params) => { +export const fetchPrometheusMetrics = ({ state, commit, dispatch, getters }, params) => { commit(types.REQUEST_METRICS_DATA); const promises = []; @@ -146,18 +150,25 @@ export const fetchPrometheusMetrics = ({ state, commit, dispatch }, params) => { }); }); - return Promise.all(promises).then(() => { - if (state.metricsWithData.length === 0) { - commit(types.SET_NO_DATA_EMPTY_STATE); - } - }); + return Promise.all(promises) + .then(() => { + const dashboardType = state.currentDashboard === '' ? 'default' : 'custom'; + trackDashboardLoad({ + label: `${dashboardType}_metrics_dashboard`, + value: getters.metricsWithData().length, + }); + }) + .catch(() => { + createFlash(s__(`Metrics|There was an error while retrieving metrics`), 'warning'); + }); }; export const fetchDeploymentsData = ({ state, dispatch }) => { if (!state.deploymentsEndpoint) { return Promise.resolve([]); } - return backOffRequest(() => axios.get(state.deploymentsEndpoint)) + return axios + .get(state.deploymentsEndpoint) .then(resp => resp.data) .then(response => { if (!response || !response.deployments) { diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js new file mode 100644 index 00000000000..a13157c6f87 --- /dev/null +++ b/app/assets/javascripts/monitoring/stores/getters.js @@ -0,0 +1,62 @@ +const metricsIdsInPanel = panel => + panel.metrics.filter(metric => metric.metricId && metric.result).map(metric => metric.metricId); + +/** + * Get all state for metric in the dashboard or a group. The + * states are not repeated so the dashboard or group can show + * a global state. + * + * @param {Object} state + * @returns {Function} A function that returns an array of + * states in all the metric in the dashboard or group. + */ +export const getMetricStates = state => groupKey => { + let groups = state.dashboard.panel_groups; + if (groupKey) { + groups = groups.filter(group => group.key === groupKey); + } + + const metricStates = groups.reduce((acc, group) => { + group.panels.forEach(panel => { + panel.metrics.forEach(metric => { + if (metric.state) { + acc.push(metric.state); + } + }); + }); + return acc; + }, []); + + // Deduplicate and sort array + return Array.from(new Set(metricStates)).sort(); +}; + +/** + * Getter to obtain the list of metric ids that have data + * + * Useful to understand which parts of the dashboard should + * be displayed. It is a Vuex Method-Style Access getter. + * + * @param {Object} state + * @returns {Function} A function that returns an array of + * metrics in the dashboard that contain results, optionally + * filtered by group key. + */ +export const metricsWithData = state => groupKey => { + let groups = state.dashboard.panel_groups; + if (groupKey) { + groups = groups.filter(group => group.key === groupKey); + } + + const res = []; + groups.forEach(group => { + group.panels.forEach(panel => { + res.push(...metricsIdsInPanel(panel)); + }); + }); + + return res; +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/index.js b/app/assets/javascripts/monitoring/stores/index.js index d58398c54ae..c1c466b7cf0 100644 --- a/app/assets/javascripts/monitoring/stores/index.js +++ b/app/assets/javascripts/monitoring/stores/index.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import * as actions from './actions'; +import * as getters from './getters'; import mutations from './mutations'; import state from './state'; @@ -12,6 +13,7 @@ export const createStore = () => monitoringDashboard: { namespaced: true, actions, + getters, mutations, state, }, diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index fa15a2ba800..74068e1d846 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -1,13 +1,19 @@ export const REQUEST_METRICS_DATA = 'REQUEST_METRICS_DATA'; export const RECEIVE_METRICS_DATA_SUCCESS = 'RECEIVE_METRICS_DATA_SUCCESS'; export const RECEIVE_METRICS_DATA_FAILURE = 'RECEIVE_METRICS_DATA_FAILURE'; + export const REQUEST_DEPLOYMENTS_DATA = 'REQUEST_DEPLOYMENTS_DATA'; export const RECEIVE_DEPLOYMENTS_DATA_SUCCESS = 'RECEIVE_DEPLOYMENTS_DATA_SUCCESS'; export const RECEIVE_DEPLOYMENTS_DATA_FAILURE = 'RECEIVE_DEPLOYMENTS_DATA_FAILURE'; + export const REQUEST_ENVIRONMENTS_DATA = 'REQUEST_ENVIRONMENTS_DATA'; export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCCESS'; export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAILURE'; -export const SET_QUERY_RESULT = 'SET_QUERY_RESULT'; + +export const REQUEST_METRIC_RESULT = 'REQUEST_METRIC_RESULT'; +export const RECEIVE_METRIC_RESULT_SUCCESS = 'RECEIVE_METRIC_RESULT_SUCCESS'; +export const RECEIVE_METRIC_RESULT_FAILURE = 'RECEIVE_METRIC_RESULT_FAILURE'; + export const SET_TIME_WINDOW = 'SET_TIME_WINDOW'; export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS'; export const SET_ENDPOINTS = 'SET_ENDPOINTS'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index 696af5aed75..16a34a6c026 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -1,11 +1,85 @@ import Vue from 'vue'; import { slugify } from '~/lib/utils/text_utility'; import * as types from './mutation_types'; -import { normalizeMetrics, normalizeMetric, normalizeQueryResult } from './utils'; +import { normalizeMetric, normalizeQueryResult } from './utils'; +import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils'; +import { metricStates } from '../constants'; +import httpStatusCodes from '~/lib/utils/http_status'; -const normalizePanel = panel => panel.metrics.map(normalizeMetric); +const normalizePanelMetrics = (metrics, defaultLabel) => + metrics.map(metric => ({ + ...normalizeMetric(metric), + label: metric.label || defaultLabel, + })); + +/** + * Locate and return a metric in the dashboard by its id + * as generated by `uniqMetricsId()`. + * @param {String} metricId Unique id in the dashboard + * @param {Object} dashboard Full dashboard object + */ +const findMetricInDashboard = (metricId, dashboard) => { + let res = null; + dashboard.panel_groups.forEach(group => { + group.panels.forEach(panel => { + panel.metrics.forEach(metric => { + if (metric.metric_id === metricId) { + res = metric; + } + }); + }); + }); + return res; +}; + +/** + * Set a new state for a metric. + * + * Initally metric data is not populated, so `Vue.set` is + * used to add new properties to the metric. + * + * @param {Object} metric - Metric object as defined in the dashboard + * @param {Object} state - New state + * @param {Array|null} state.result - Array of results + * @param {String} state.error - Error code from metricStates + * @param {Boolean} state.loading - True if the metric is loading + */ +const setMetricState = (metric, { result = null, loading = false, state = null }) => { + Vue.set(metric, 'result', result); + Vue.set(metric, 'loading', loading); + Vue.set(metric, 'state', state); +}; + +/** + * Maps a backened error state to a `metricStates` constant + * @param {Object} error - Error from backend response + */ +const emptyStateFromError = error => { + if (!error) { + return metricStates.UNKNOWN_ERROR; + } + + // Special error responses + if (error.message === BACKOFF_TIMEOUT) { + return metricStates.TIMEOUT; + } + + // Axios error responses + const { response } = error; + if (response && response.status === httpStatusCodes.SERVICE_UNAVAILABLE) { + return metricStates.CONNECTION_FAILED; + } else if (response && response.status === httpStatusCodes.BAD_REQUEST) { + // Note: "error.response.data.error" may contain Prometheus error information + return metricStates.BAD_QUERY; + } + + return metricStates.UNKNOWN_ERROR; +}; export default { + /** + * Dashboard panels structure and global state + */ [types.REQUEST_METRICS_DATA](state) { state.emptyState = 'loading'; state.showEmptyState = true; @@ -13,28 +87,18 @@ export default { [types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) { state.dashboard.panel_groups = groupData.map((group, i) => { const key = `${slugify(group.group || 'default')}-${i}`; - let { metrics = [], panels = [] } = group; + let { panels = [] } = group; // each panel has metric information that needs to be normalized - panels = panels.map(panel => ({ ...panel, - metrics: normalizePanel(panel), - })); - - // for backwards compatibility, and to limit Vue template changes: - // for each group alias panels to metrics - // for each panel alias metrics to queries - metrics = panels.map(panel => ({ - ...panel, - queries: panel.metrics, + metrics: normalizePanelMetrics(panel.metrics, panel.y_label), })); return { ...group, panels, key, - metrics: normalizeMetrics(metrics), }; }); @@ -46,6 +110,10 @@ export default { state.emptyState = error ? 'unableToConnect' : 'noData'; state.showEmptyState = true; }, + + /** + * Deployments and environments + */ [types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS](state, deployments) { state.deploymentData = deployments; }, @@ -58,26 +126,47 @@ export default { [types.RECEIVE_ENVIRONMENTS_DATA_FAILURE](state) { state.environments = []; }, - [types.SET_QUERY_RESULT](state, { metricId, result }) { - if (!metricId || !result || result.length === 0) { + + /** + * Individual panel/metric results + */ + [types.REQUEST_METRIC_RESULT](state, { metricId }) { + const metric = findMetricInDashboard(metricId, state.dashboard); + setMetricState(metric, { + loading: true, + state: metricStates.LOADING, + }); + }, + [types.RECEIVE_METRIC_RESULT_SUCCESS](state, { metricId, result }) { + if (!metricId) { return; } state.showEmptyState = false; - state.dashboard.panel_groups.forEach(group => { - group.metrics.forEach(metric => { - metric.queries.forEach(query => { - if (query.metric_id === metricId) { - state.metricsWithData.push(metricId); - // ensure dates/numbers are correctly formatted for charts - const normalizedResults = result.map(normalizeQueryResult); - Vue.set(query, 'result', Object.freeze(normalizedResults)); - } - }); + const metric = findMetricInDashboard(metricId, state.dashboard); + if (!result || result.length === 0) { + setMetricState(metric, { + state: metricStates.NO_DATA, }); + } else { + const normalizedResults = result.map(normalizeQueryResult); + setMetricState(metric, { + result: Object.freeze(normalizedResults), + state: metricStates.OK, + }); + } + }, + [types.RECEIVE_METRIC_RESULT_FAILURE](state, { metricId, error }) { + if (!metricId) { + return; + } + const metric = findMetricInDashboard(metricId, state.dashboard); + setMetricState(metric, { + state: emptyStateFromError(error), }); }, + [types.SET_ENDPOINTS](state, endpoints) { state.metricsEndpoint = endpoints.metricsEndpoint; state.environmentsEndpoint = endpoints.environmentsEndpoint; @@ -101,6 +190,6 @@ export default { }, [types.SET_PANEL_GROUP_METRICS](state, payload) { const panelGroup = state.dashboard.panel_groups.find(pg => payload.key === pg.key); - panelGroup.metrics = payload.metrics; + panelGroup.panels = payload.panels; }, }; diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index 87e94311176..ee8a85ea222 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -1,8 +1,6 @@ import invalidUrl from '~/lib/utils/invalid_url'; export default () => ({ - hasMetrics: false, - showPanels: true, metricsEndpoint: null, environmentsEndpoint: null, deploymentsEndpoint: null, @@ -10,12 +8,13 @@ export default () => ({ emptyState: 'gettingStarted', showEmptyState: true, showErrorBanner: true, + dashboard: { panel_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 8a396b15a31..3300d2032d0 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -1,83 +1,21 @@ import _ from 'underscore'; -function checkQueryEmptyData(query) { - return { - ...query, - result: query.result.filter(timeSeries => { - const newTimeSeries = timeSeries; - const hasValue = series => - !Number.isNaN(series[1]) && (series[1] !== null || series[1] !== undefined); - const hasNonNullValue = timeSeries.values.find(hasValue); - - newTimeSeries.values = hasNonNullValue ? newTimeSeries.values : []; - - return newTimeSeries.values.length > 0; - }), - }; -} - -function removeTimeSeriesNoData(queries) { - return queries.reduce((series, query) => series.concat(checkQueryEmptyData(query)), []); -} - -// Metrics and queries are currently stored 1:1, so `queries` is an array of length one. -// We want to group queries onto a single chart by title & y-axis label. -// This function will no longer be required when metrics:queries are 1:many, -// though there is no consequence if the function stays in use. -// @param metrics [Array<Object>] -// Ex) [ -// { id: 1, title: 'title', y_label: 'MB', queries: [{ ...query1Attrs }] }, -// { id: 2, title: 'title', y_label: 'MB', queries: [{ ...query2Attrs }] }, -// { id: 3, title: 'new title', y_label: 'MB', queries: [{ ...query3Attrs }] } -// ] -// @return [Array<Object>] -// Ex) [ -// { title: 'title', y_label: 'MB', queries: [{ metricId: 1, ...query1Attrs }, -// { metricId: 2, ...query2Attrs }] }, -// { title: 'new title', y_label: 'MB', queries: [{ metricId: 3, ...query3Attrs }]} -// ] -export function groupQueriesByChartInfo(metrics) { - const metricsByChart = metrics.reduce((accumulator, metric) => { - const { queries, ...chart } = metric; - - const chartKey = `${chart.title}|${chart.y_label}`; - accumulator[chartKey] = accumulator[chartKey] || { ...chart, queries: [] }; - - queries.forEach(queryAttrs => { - let metricId; - - if (chart.id) { - metricId = chart.id.toString(); - } else if (queryAttrs.metric_id) { - metricId = queryAttrs.metric_id.toString(); - } else { - metricId = null; - } - - accumulator[chartKey].queries.push({ metricId, ...queryAttrs }); - }); - - return accumulator; - }, {}); - - return Object.values(metricsByChart); -} - export const uniqMetricsId = metric => `${metric.metric_id}_${metric.id}`; /** - * Not to confuse with normalizeMetrics (plural) * Metrics loaded from project-defined dashboards do not have a metric_id. * This method creates a unique ID combining metric_id and id, if either is present. * This is hopefully a temporary solution until BE processes metrics before passing to fE * @param {Object} metric - metric * @returns {Object} - normalized metric with a uniqueID */ + export const normalizeMetric = (metric = {}) => _.omit( { ...metric, metric_id: uniqMetricsId(metric), + metricId: uniqMetricsId(metric), }, 'id', ); @@ -93,6 +31,11 @@ export const normalizeQueryResult = timeSeries => { Number(value), ]), }; + // Check result for empty data + normalizedResult.values = normalizedResult.values.filter(series => { + const hasValue = d => !Number.isNaN(d[1]) && (d[1] !== null || d[1] !== undefined); + return series.find(hasValue); + }); } else if (timeSeries.value) { normalizedResult = { ...timeSeries, @@ -102,21 +45,3 @@ export const normalizeQueryResult = timeSeries => { return normalizedResult; }; - -export const normalizeMetrics = metrics => { - const groupedMetrics = groupQueriesByChartInfo(metrics); - - return groupedMetrics.map(metric => { - const queries = metric.queries.map(query => ({ - ...query, - // custom metrics do not require a label, so we should ensure this attribute is defined - label: query.label || metric.y_label, - result: (query.result || []).map(normalizeQueryResult), - })); - - return { - ...metric, - queries: removeTimeSeriesNoData(queries), - }; - }); -}; diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index 2ae1647011d..c824d6d4ddb 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -72,10 +72,9 @@ export const ISODateToString = date => dateformat(date, dateFormats.dateTimePick */ export const graphDataValidatorForValues = (isValues, graphData) => { const responseValueKeyName = isValues ? 'value' : 'values'; - return ( - Array.isArray(graphData.queries) && - graphData.queries.filter(query => { + Array.isArray(graphData.metrics) && + graphData.metrics.filter(query => { if (Array.isArray(query.result)) { return ( query.result.filter(res => Array.isArray(res[responseValueKeyName])).length === @@ -83,7 +82,7 @@ export const graphDataValidatorForValues = (isValues, graphData) => { ); } return false; - }).length === graphData.queries.length + }).length === graphData.metrics.filter(query => query.result).length ); }; @@ -116,6 +115,7 @@ export const generateLinkToChartOptions = chartLink => { /** * Tracks snowplow event when user downloads CSV of cluster metric * @param {String} chart title that will be sent as a property for the event + * @return {Object} config object for event tracking */ export const downloadCSVOptions = title => { const isCLusterHealthBoard = isClusterHealthBoard(); @@ -131,7 +131,19 @@ export const downloadCSVOptions = title => { }; /** - * This function validates the graph data contains exactly 3 queries plus + * Generate options for snowplow to track adding a new metric via the dashboard + * custom metric modal + * @return {Object} config object for event tracking + */ +export const getAddMetricTrackingOptions = () => ({ + category: document.body.dataset.page, + action: 'click_button', + label: 'add_new_metric', + property: 'modal', +}); + +/** + * This function validates the graph data contains exactly 3 metrics plus * value validations from graphDataValidatorForValues. * @param {Object} isValues * @param {Object} graphData the graph data response from a prometheus request @@ -140,8 +152,8 @@ export const downloadCSVOptions = title => { export const graphDataValidatorForAnomalyValues = graphData => { const anomalySeriesCount = 3; // metric, upper, lower return ( - graphData.queries && - graphData.queries.length === anomalySeriesCount && + graphData.metrics && + graphData.metrics.length === anomalySeriesCount && graphDataValidatorForValues(false, graphData) ); }; diff --git a/app/assets/javascripts/mr_popover/components/mr_popover.vue b/app/assets/javascripts/mr_popover/components/mr_popover.vue index b81600660f6..ce08b0964a1 100644 --- a/app/assets/javascripts/mr_popover/components/mr_popover.vue +++ b/app/assets/javascripts/mr_popover/components/mr_popover.vue @@ -45,7 +45,7 @@ export default { return this.mergeRequest.headPipeline && this.mergeRequest.headPipeline.detailedStatus; }, formattedTime() { - return this.timeFormated(this.mergeRequest.createdAt); + return this.timeFormatted(this.mergeRequest.createdAt); }, statusBoxClass() { switch (this.mergeRequest.state) { diff --git a/app/assets/javascripts/mr_tabs_popover/components/popover.vue b/app/assets/javascripts/mr_tabs_popover/components/popover.vue new file mode 100644 index 00000000000..da1e1e70993 --- /dev/null +++ b/app/assets/javascripts/mr_tabs_popover/components/popover.vue @@ -0,0 +1,64 @@ +<script> +import { GlPopover, GlButton, GlLink } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import axios from '~/lib/utils/axios_utils'; + +export default { + components: { + GlPopover, + GlButton, + GlLink, + Icon, + }, + props: { + dismissEndpoint: { + type: String, + required: true, + }, + featureId: { + type: String, + required: true, + }, + }, + data() { + return { + showPopover: false, + }; + }, + mounted() { + setTimeout(() => { + this.showPopover = true; + }, 2000); + }, + methods: { + onDismiss() { + this.showPopover = false; + + axios.post(this.dismissEndpoint, { + feature_name: this.featureId, + }); + }, + }, +}; +</script> + +<template> + <gl-popover target="#diffs-tab" placement="bottom" :show="showPopover"> + <p class="mb-2"> + {{ + __( + 'Now you can access the merge request navigation tabs at the top, where they’re easier to find.', + ) + }} + </p> + <p> + <gl-link href="https://gitlab.com/gitlab-org/gitlab/issues/36125" target="_blank"> + {{ __('More information and share feedback') }} + <icon name="external-link" :size="10" /> + </gl-link> + </p> + <gl-button variant="primary" size="sm" @click="onDismiss"> + {{ __('Got it') }} + </gl-button> + </gl-popover> +</template> diff --git a/app/assets/javascripts/mr_tabs_popover/index.js b/app/assets/javascripts/mr_tabs_popover/index.js new file mode 100644 index 00000000000..9ee0ba046f0 --- /dev/null +++ b/app/assets/javascripts/mr_tabs_popover/index.js @@ -0,0 +1,12 @@ +import Vue from 'vue'; +import Popover from './components/popover.vue'; + +export default el => + new Vue({ + el, + render(createElement) { + return createElement(Popover, { + props: { dismissEndpoint: el.dataset.dismissEndpoint, featureId: el.dataset.featureId }, + }); + }, + }); diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue index 9e4a92426ee..753aa96bb55 100644 --- a/app/assets/javascripts/notebook/cells/markdown.vue +++ b/app/assets/javascripts/notebook/cells/markdown.vue @@ -1,7 +1,7 @@ <script> -/* global katex */ import marked from 'marked'; import sanitize from 'sanitize-html'; +import katex from 'katex'; import Prompt from './prompt.vue'; const renderer = new marked.Renderer(); @@ -70,7 +70,6 @@ renderer.paragraph = t => { }; marked.setOptions({ - sanitize: true, renderer, }); @@ -87,9 +86,66 @@ export default { computed: { markdown() { return sanitize(marked(this.cell.source.join('').replace(/\\/g, '\\\\')), { - allowedTags: false, + // allowedTags from GitLab's inline HTML guidelines + // https://docs.gitlab.com/ee/user/markdown.html#inline-html + allowedTags: [ + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'h7', + 'h8', + 'br', + 'b', + 'i', + 'strong', + 'em', + 'a', + 'pre', + 'code', + 'img', + 'tt', + 'div', + 'ins', + 'del', + 'sup', + 'sub', + 'p', + 'ol', + 'ul', + 'table', + 'thead', + 'tbody', + 'tfoot', + 'blockquote', + 'dl', + 'dt', + 'dd', + 'kbd', + 'q', + 'samp', + 'var', + 'hr', + 'ruby', + 'rt', + 'rp', + 'li', + 'tr', + 'td', + 'th', + 's', + 'strike', + 'span', + 'abbr', + 'abbr', + 'summary', + ], allowedAttributes: { - '*': ['class'], + '*': ['class', 'style'], + a: ['href'], + img: ['src'], }, }); }, @@ -105,6 +161,15 @@ export default { </template> <style> +/* + Importing the necessary katex stylesheet from the node_module folder rather + than copying the stylesheet into `app/assets/stylesheets/vendors` for + automatic importing via `app/assets/stylesheets/application.scss`. The reason + is that the katex stylesheet depends on many fonts that are in node_module + subfolders - moving all these fonts would make updating katex difficult. + */ +@import '~katex/dist/katex.min.css'; + .markdown .katex { display: block; text-align: center; diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index defa278c089..1a8f1c659a4 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1,7 +1,7 @@ -/* eslint-disable no-restricted-properties, no-var, camelcase, -no-unused-expressions, one-var, default-case, +/* eslint-disable no-restricted-properties, camelcase, +no-unused-expressions, default-case, consistent-return, no-alert, no-param-reassign, no-else-return, -vars-on-top, no-shadow, no-useless-escape, +no-shadow, no-useless-escape, class-methods-use-this */ /* global ResolveService */ @@ -16,10 +16,10 @@ import Cookies from 'js-cookie'; import Autosize from 'autosize'; import 'jquery.caret'; // required by at.js import 'at.js'; -import AjaxCache from '~/lib/utils/ajax_cache'; import Vue from 'vue'; -import syntaxHighlight from '~/syntax_highlight'; import { GlSkeletonLoading } from '@gitlab/ui'; +import AjaxCache from '~/lib/utils/ajax_cache'; +import syntaxHighlight from '~/syntax_highlight'; import axios from './lib/utils/axios_utils'; import { getLocationHash } from './lib/utils/url_utility'; import Flash from './flash'; @@ -224,18 +224,18 @@ export default class Notes { } keydownNoteText(e) { - var $textarea, - discussionNoteForm, - editNote, - myLastNote, - myLastNoteEditBtn, - newText, - originalText; + let discussionNoteForm; + let editNote; + let myLastNote; + let myLastNoteEditBtn; + let newText; + let originalText; + if (isMetaKey(e)) { return; } - $textarea = $(e.target); + const $textarea = $(e.target); // Edit previous note when UP arrow is hit switch (e.which) { case 38: @@ -325,11 +325,10 @@ export default class Notes { * if there aren't new notes coming from the server */ setPollingInterval(shouldReset) { - var nthInterval; if (shouldReset == null) { shouldReset = true; } - nthInterval = this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1); + const nthInterval = this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1); if (shouldReset) { this.pollingInterval = this.basePollingInterval; } else if (this.pollingInterval < nthInterval) { @@ -339,7 +338,7 @@ export default class Notes { } handleQuickActions(noteEntity) { - var votesBlock; + let votesBlock; if (noteEntity.commands_changes) { if ('merge' in noteEntity.commands_changes) { Notes.checkMergeRequestStatus(); @@ -462,14 +461,16 @@ export default class Notes { * Render note in discussion area. To render inline notes use renderDiscussionNote. */ renderDiscussionNote(noteEntity, $form) { - var discussionContainer, form, row, lineType, diffAvatarContainer; + let discussionContainer; + let row; if (!Notes.isNewNote(noteEntity, this.note_ids)) { return; } this.note_ids.push(noteEntity.id); - form = $form || $(`.js-discussion-note-form[data-discussion-id="${noteEntity.discussion_id}"]`); + const form = + $form || $(`.js-discussion-note-form[data-discussion-id="${noteEntity.discussion_id}"]`); row = form.length || !noteEntity.discussion_line_code ? form.closest('tr') @@ -479,8 +480,8 @@ export default class Notes { row = form; } - lineType = this.isParallelView() ? form.find('#line_type').val() : 'old'; - diffAvatarContainer = row + const lineType = this.isParallelView() ? form.find('#line_type').val() : 'old'; + const diffAvatarContainer = row .prevAll('.line_holder') .first() .find(`.js-avatar-container.${lineType}_line`); @@ -491,15 +492,17 @@ export default class Notes { } if (discussionContainer.length === 0) { if (noteEntity.diff_discussion_html) { - var $discussion = $(noteEntity.diff_discussion_html).renderGFM(); + const $discussion = $(noteEntity.diff_discussion_html).renderGFM(); if (!this.isParallelView() || row.hasClass('js-temp-notes-holder') || noteEntity.on_image) { // insert the note and the reply button after the temp row row.after($discussion); } else { // Merge new discussion HTML in - var $notes = $discussion.find(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`); - var contentContainerClass = $notes + const $notes = $discussion.find( + `.notes[data-discussion-id="${noteEntity.discussion_id}"]`, + ); + const contentContainerClass = $notes .closest('.notes-content') .attr('class') .split(' ') @@ -537,7 +540,7 @@ export default class Notes { } renderDiscussionAvatar(diffAvatarContainer, noteEntity) { - var avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders'); + let avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders'); if (!avatarHolder.length) { avatarHolder = document.createElement('diff-note-avatars'); @@ -557,8 +560,7 @@ export default class Notes { * Resets buttons. */ resetMainTargetForm(e) { - var form; - form = $('.js-main-target-form'); + const form = $('.js-main-target-form'); // remove validation errors form.find('.js-errors').remove(); // reset text and preview @@ -572,7 +574,7 @@ export default class Notes { .data('autosave') .reset(); - var event = document.createEvent('Event'); + const event = document.createEvent('Event'); event.initEvent('autosize:update', true, false); form.find('.js-autosize')[0].dispatchEvent(event); @@ -580,8 +582,7 @@ export default class Notes { } reenableTargetFormSubmitButton() { - var form; - form = $('.js-main-target-form'); + const form = $('.js-main-target-form'); return form.find('.js-note-text').trigger('input'); } @@ -591,9 +592,8 @@ export default class Notes { * Sets some hidden fields in the form. */ setupMainTargetNoteForm(enableGFM) { - var form; // find the form - form = $('.js-new-note-form'); + const form = $('.js-new-note-form'); // Set a global clone of the form for later cloning this.formClone = form.clone(); // show the form @@ -626,10 +626,9 @@ export default class Notes { * show the form */ setupNoteForm(form, enableGFM = defaultAutocompleteConfig) { - var textarea, key; this.glForm = new GLForm(form, enableGFM); - textarea = form.find('.js-note-text'); - key = [ + const textarea = form.find('.js-note-text'); + const key = [ s__('NoteForm|Note'), form.find('#note_noteable_type').val(), form.find('#note_noteable_id').val(), @@ -686,8 +685,8 @@ export default class Notes { */ addDiscussionNote($form, note, isNewDiffComment) { if ($form.attr('data-resolve-all') != null) { - var discussionId = $form.data('discussionId'); - var mergeRequestId = $form.data('noteableIid'); + const discussionId = $form.data('discussionId'); + const mergeRequestId = $form.data('noteableIid'); if (ResolveService != null) { ResolveService.toggleResolveForDiscussion(mergeRequestId, discussionId); @@ -707,13 +706,12 @@ export default class Notes { * Updates the current note field. */ updateNote(noteEntity, $targetNote) { - var $noteEntityEl, $note_li; // Convert returned HTML to a jQuery object so we can modify it further - $noteEntityEl = $(noteEntity.html); + const $noteEntityEl = $(noteEntity.html); this.revertNoteEditForm($targetNote); $noteEntityEl.renderGFM(); // Find the note's `li` element by ID and replace it with the updated HTML - $note_li = $(`.note-row-${noteEntity.id}`); + const $note_li = $(`.note-row-${noteEntity.id}`); $note_li.replaceWith($noteEntityEl); this.setupNewNote($noteEntityEl); @@ -724,17 +722,17 @@ export default class Notes { } checkContentToAllowEditing($el) { - var initialContent = $el + const initialContent = $el .find('.original-note-content') .text() .trim(); - var currentContent = $el.find('.js-note-text').val(); - var isAllowed = true; + const currentContent = $el.find('.js-note-text').val(); + let isAllowed = true; if (currentContent === initialContent) { this.removeNoteEditForm($el); } else { - var isWidgetVisible = isInViewport($el.get(0)); + const isWidgetVisible = isInViewport($el.get(0)); if (!isWidgetVisible) { scrollToElement($el); @@ -756,13 +754,13 @@ export default class Notes { showEditForm(e) { e.preventDefault(); - var $target = $(e.target); - var $editForm = $(this.getEditFormSelector($target)); - var $note = $target.closest('.note'); - var $currentlyEditing = $('.note.is-editing:visible'); + const $target = $(e.target); + const $editForm = $(this.getEditFormSelector($target)); + const $note = $target.closest('.note'); + const $currentlyEditing = $('.note.is-editing:visible'); if ($currentlyEditing.length) { - var isEditAllowed = this.checkContentToAllowEditing($currentlyEditing); + const isEditAllowed = this.checkContentToAllowEditing($currentlyEditing); if (!isEditAllowed) { return; @@ -802,8 +800,8 @@ export default class Notes { revertNoteEditForm($target) { $target = $target || $('.note.is-editing:visible'); - var selector = this.getEditFormSelector($target); - var $editForm = $(selector); + const selector = this.getEditFormSelector($target); + const $editForm = $(selector); $editForm.insertBefore('.diffs'); $editForm.find('.js-comment-save-button').enable(); @@ -811,7 +809,7 @@ export default class Notes { } getEditFormSelector($el) { - var selector = '.note-edit-form:not(.mr-note-edit-form)'; + let selector = '.note-edit-form:not(.mr-note-edit-form)'; if ($el.parents('#diffs').length) { selector = '.note-edit-form.mr-note-edit-form'; @@ -821,7 +819,7 @@ export default class Notes { } removeNoteEditForm($note) { - var form = $note.find('.diffs .current-note-edit-form'); + const form = $note.find('.diffs .current-note-edit-form'); $note.removeClass('is-editing'); form.removeClass('current-note-edit-form'); @@ -837,9 +835,8 @@ export default class Notes { * Removes the whole discussion if the last note is being removed. */ removeNote(e) { - var noteElId, $note; - $note = $(e.currentTarget).closest('.note'); - noteElId = $note.attr('id'); + const $note = $(e.currentTarget).closest('.note'); + const noteElId = $note.attr('id'); $(`.note[id="${noteElId}"]`).each((i, el) => { // A same note appears in the "Discussion" and in the "Changes" tab, we have // to remove all. Using $('.note[id='noteId']') ensure we get all the notes, @@ -915,9 +912,8 @@ export default class Notes { } replyToDiscussionNote(target) { - var form, replyLink; - form = this.cleanForm(this.formClone.clone()); - replyLink = $(target).closest('.js-discussion-reply-button'); + const form = this.cleanForm(this.formClone.clone()); + const replyLink = $(target).closest('.js-discussion-reply-button'); // insert the form after the button replyLink .closest('.discussion-reply-holder') @@ -942,7 +938,7 @@ export default class Notes { diffFileData = dataHolder.closest('.image'); } - var discussionID = dataHolder.data('discussionId'); + const discussionID = dataHolder.data('discussionId'); if (discussionID) { form.attr('data-discussion-id', discussionID); @@ -985,7 +981,7 @@ export default class Notes { form.removeClass('js-main-target-form').addClass('discussion-form js-discussion-note-form'); if (typeof gl.diffNotesCompileComponents !== 'undefined') { - var $commentBtn = form.find('comment-and-resolve-btn'); + const $commentBtn = form.find('comment-and-resolve-btn'); $commentBtn.attr(':discussion-id', `'${discussionID}'`); gl.diffNotesCompileComponents(); @@ -1042,16 +1038,20 @@ export default class Notes { } toggleDiffNote({ target, lineType, forceShow, showReplyInput = false }) { - var $link, addForm, hasNotes, newForm, noteForm, replyButton, row, rowCssToAdd; - $link = $(target); - row = $link.closest('tr'); + let addForm; + let newForm; + let noteForm; + let replyButton; + let rowCssToAdd; + const $link = $(target); + const row = $link.closest('tr'); const nextRow = row.next(); let targetRow = row; if (nextRow.is('.notes_holder')) { targetRow = nextRow; } - hasNotes = nextRow.is('.notes_holder'); + const hasNotes = nextRow.is('.notes_holder'); addForm = false; let lineTypeSelector = ''; rowCssToAdd = @@ -1111,9 +1111,8 @@ export default class Notes { * Removes the form and if necessary it's temporary row. */ removeDiscussionNoteForm(form) { - var glForm, row; - row = form.closest('tr'); - glForm = form.data('glForm'); + const row = form.closest('tr'); + const glForm = form.data('glForm'); glForm.destroy(); form .find('.js-note-text') @@ -1158,10 +1157,9 @@ export default class Notes { * Updates the file name for the selected attachment. */ updateFormAttachment() { - var filename, form; - form = $(this).closest('form'); + const form = $(this).closest('form'); // get only the basename - filename = $(this) + const filename = $(this) .val() .replace(/^.*[\\\/]/, ''); return form.find('.js-attachment-filename').text(filename); @@ -1175,11 +1173,12 @@ export default class Notes { } updateTargetButtons(e) { - var closebtn, closetext, form, reopenbtn, reopentext, textarea; - textarea = $(e.target); - form = textarea.parents('form'); - reopenbtn = form.find('.js-note-target-reopen'); - closebtn = form.find('.js-note-target-close'); + let closetext; + let reopentext; + const textarea = $(e.target); + const form = textarea.parents('form'); + const reopenbtn = form.find('.js-note-target-reopen'); + const closebtn = form.find('.js-note-target-close'); if (textarea.val().trim().length > 0) { reopentext = reopenbtn.attr('data-alternative-text'); @@ -1215,16 +1214,16 @@ export default class Notes { } putEditFormInPlace($el) { - var $editForm = $(this.getEditFormSelector($el)); - var $note = $el.closest('.note'); + const $editForm = $(this.getEditFormSelector($el)); + const $note = $el.closest('.note'); $editForm.insertAfter($note.find('.note-text')); - var $originalContentEl = $note.find('.original-note-content'); - var originalContent = $originalContentEl.text().trim(); - var postUrl = $originalContentEl.data('postUrl'); - var targetId = $originalContentEl.data('targetId'); - var targetType = $originalContentEl.data('targetType'); + const $originalContentEl = $note.find('.original-note-content'); + const originalContent = $originalContentEl.text().trim(); + const postUrl = $originalContentEl.data('postUrl'); + const targetId = $originalContentEl.data('targetId'); + const targetType = $originalContentEl.data('targetType'); this.glForm = new GLForm($editForm.find('form'), this.enableGFM); diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index fda494fec07..492d8de3802 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -193,23 +193,10 @@ export default { this.stopPolling(); this.saveNote(noteData) - .then(res => { + .then(() => { this.enableButton(); this.restartPolling(); - - if (res.errors) { - if (res.errors.commands_only) { - this.discard(); - } else { - Flash( - __('Something went wrong while adding your comment. Please try again.'), - 'alert', - this.$refs.commentForm, - ); - } - } else { - this.discard(); - } + this.discard(); if (withIssueAction) { this.toggleIssueState(); diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index df537ba1ed2..fe22737c7fc 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -1,10 +1,10 @@ <script> /* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { mapState, mapActions } from 'vuex'; +import { GlSkeletonLoading } from '@gitlab/ui'; import DiffFileHeader from '~/diffs/components/diff_file_header.vue'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue'; -import { GlSkeletonLoading } from '@gitlab/ui'; import { getDiffMode } from '~/diffs/store/utils'; import { diffViewerModes } from '~/ide/constants'; diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index d7ffa0abb79..98f1f385e9b 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -19,6 +19,7 @@ export default { 'resolvableDiscussionsCount', 'firstUnresolvedDiscussionId', 'unresolvedDiscussionsCount', + 'getDiscussion', ]), isLoggedIn() { return this.getUserData.id; @@ -40,9 +41,10 @@ export default { ...mapActions(['expandDiscussion']), jumpToFirstUnresolvedDiscussion() { const diffTab = window.mrTabs.currentAction === 'diffs'; - const discussionId = this.firstUnresolvedDiscussionId(diffTab); - - this.jumpToDiscussion(discussionId); + const discussionId = + this.firstUnresolvedDiscussionId(diffTab) || this.firstUnresolvedDiscussionId(); + const firstDiscussion = this.getDiscussion(discussionId); + this.jumpToDiscussion(firstDiscussion); }, }, }; diff --git a/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue b/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue index 07a5bda6bcb..f87ca097b40 100644 --- a/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue +++ b/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue @@ -1,6 +1,6 @@ <script> -import icon from '~/vue_shared/components/icon.vue'; import { GlTooltipDirective } from '@gitlab/ui'; +import icon from '~/vue_shared/components/icon.vue'; export default { name: 'JumpToNextDiscussionButton', diff --git a/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue b/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue index 7fbfe8eebb2..7d742fbfeee 100644 --- a/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue +++ b/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue @@ -19,7 +19,11 @@ export default { }; }, computed: { - ...mapGetters(['nextUnresolvedDiscussionId', 'previousUnresolvedDiscussionId']), + ...mapGetters([ + 'nextUnresolvedDiscussionId', + 'previousUnresolvedDiscussionId', + 'getDiscussion', + ]), }, mounted() { Mousetrap.bind('n', () => this.jumpToNextDiscussion()); @@ -33,14 +37,14 @@ export default { ...mapActions(['expandDiscussion']), jumpToNextDiscussion() { const nextId = this.nextUnresolvedDiscussionId(this.currentDiscussionId, this.isDiffView); - - this.jumpToDiscussion(nextId); + const nextDiscussion = this.getDiscussion(nextId); + this.jumpToDiscussion(nextDiscussion); this.currentDiscussionId = nextId; }, jumpToPreviousDiscussion() { const prevId = this.previousUnresolvedDiscussionId(this.currentDiscussionId, this.isDiffView); - - this.jumpToDiscussion(prevId); + const prevDiscussion = this.getDiscussion(prevId); + this.jumpToDiscussion(prevDiscussion); this.currentDiscussionId = prevId; }, }, diff --git a/app/assets/javascripts/notes/components/discussion_locked_widget.vue b/app/assets/javascripts/notes/components/discussion_locked_widget.vue index 53f509185a8..8636984c6af 100644 --- a/app/assets/javascripts/notes/components/discussion_locked_widget.vue +++ b/app/assets/javascripts/notes/components/discussion_locked_widget.vue @@ -12,6 +12,9 @@ export default { }, mixins: [Issuable, issuableStateMixin], computed: { + projectArchivedWarning() { + return __('This project is archived and cannot be commented on.'); + }, lockedIssueWarning() { return sprintf( __('This %{issuableDisplayName} is locked. Only project members can comment.'), @@ -26,9 +29,15 @@ export default { <div class="disabled-comment text-center"> <span class="issuable-note-warning inline"> <icon :size="16" name="lock" class="icon" /> - <span> - {{ lockedIssueWarning }} + <span v-if="isProjectArchived"> + {{ projectArchivedWarning }} + <gl-link :href="archivedProjectDocsPath" target="_blank" class="learn-more"> + {{ __('Learn more') }} + </gl-link> + </span> + <span v-else> + {{ lockedIssueWarning }} <gl-link :href="lockedIssueDocsPath" target="_blank" class="learn-more"> {{ __('Learn more') }} </gl-link> diff --git a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue index f03e6fd73d7..1d1529bfa5b 100644 --- a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue +++ b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue @@ -1,6 +1,6 @@ <script> -import Icon from '~/vue_shared/components/icon.vue'; import { GlTooltipDirective, GlButton } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; export default { name: 'ResolveWithIssueButton', diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 89d434a60ba..dc514f00801 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -1,8 +1,8 @@ <script> import { mapGetters } from 'vuex'; -import Icon from '~/vue_shared/components/icon.vue'; import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import resolvedStatusMixin from 'ee_else_ce/batch_comments/mixins/resolved_status'; +import Icon from '~/vue_shared/components/icon.vue'; import ReplyButton from './note_actions/reply_button.vue'; export default { diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index d4a57d5d58d..df62e379017 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -116,16 +116,20 @@ export default { // We have 10+ awarded user, join them with comma and add `and x more`. if (remainingAwardList.length) { - title = sprintf(__(`%{listToShow}, and %{awardsListLength} more.`), { - listToShow: namesToShow.join(', '), - awardsListLength: remainingAwardList.length, - }); + title = sprintf( + __(`%{listToShow}, and %{awardsListLength} more.`), + { + listToShow: namesToShow.join(', '), + awardsListLength: remainingAwardList.length, + }, + false, + ); } else if (namesToShow.length > 1) { // Join all names with comma but not the last one, it will be added with and text. title = namesToShow.slice(0, namesToShow.length - 1).join(', '); // If we have more than 2 users we need an extra comma before and text. title += namesToShow.length > 2 ? ',' : ''; - title += sprintf(__(` and %{sliced}`), { sliced: namesToShow.slice(-1) }); // Append and text + title += sprintf(__(` and %{sliced}`), { sliced: namesToShow.slice(-1) }, false); // Append and text } else { // We have only 2 users so join them with and. title = namesToShow.join(__(' and ')); diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 222badf70d1..b024884bea0 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -1,7 +1,7 @@ <script> -import { mergeUrlParams } from '~/lib/utils/url_utility'; import { mapGetters, mapActions } from 'vuex'; import noteFormMixin from 'ee_else_ce/notes/mixins/note_form'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; import eventHub from '../event_hub'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; import markdownField from '../../vue_shared/components/markdown/field.vue'; diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 47ec740b63a..1f31720ff40 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -1,10 +1,10 @@ <script> import { mapActions, mapGetters } from 'vuex'; import { GlTooltipDirective } from '@gitlab/ui'; +import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form'; import { s__, __ } from '~/locale'; import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave'; import icon from '~/vue_shared/components/icon.vue'; -import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import Flash from '../../flash'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; @@ -84,6 +84,7 @@ export default { 'hasUnresolvedDiscussions', 'showJumpToNextDiscussion', 'getUserData', + 'getDiscussion', ]), currentUser() { return this.getUserData; @@ -197,23 +198,22 @@ export default { data: postData, }; - this.isReplying = false; this.saveNote(replyData) - .then(() => { - clearDraft(this.autosaveKey); + .then(res => { + if (res.hasFlash !== true) { + this.isReplying = false; + clearDraft(this.autosaveKey); + } callback(); }) .catch(err => { this.removePlaceholderNotes(); - this.isReplying = true; - this.$nextTick(() => { - const msg = __( - 'Your comment could not be submitted! Please check your network connection and try again.', - ); - Flash(msg, 'alert', this.$el); - this.$refs.noteForm.note = noteText; - callback(err); - }); + const msg = __( + 'Your comment could not be submitted! Please check your network connection and try again.', + ); + Flash(msg, 'alert', this.$el); + this.$refs.noteForm.note = noteText; + callback(err); }); }, jumpToNextDiscussion() { @@ -221,8 +221,9 @@ export default { this.discussion.id, this.discussionsByDiffOrder, ); + const nextDiscussion = this.getDiscussion(nextId); - this.jumpToDiscussion(nextId); + this.jumpToDiscussion(nextDiscussion); }, deleteNoteHandler(note) { this.$emit('noteDeleted', this.discussion, note); diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index fa8fc7d02e4..b3dae69d0bc 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -2,9 +2,9 @@ import $ from 'jquery'; import { mapGetters, mapActions } from 'vuex'; import { escape } from 'underscore'; +import draftMixin from 'ee_else_ce/notes/mixins/draft'; import { truncateSha } from '~/lib/utils/text_utility'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; -import draftMixin from 'ee_else_ce/notes/mixins/draft'; import { __, s__, sprintf } from '../../locale'; import Flash from '../../flash'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 9d1de4ef8a0..be2adb07526 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -1,5 +1,4 @@ <script> -import { __ } from '~/locale'; import { mapGetters, mapActions } from 'vuex'; import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility'; import Flash from '../../flash'; @@ -14,6 +13,7 @@ import placeholderNote from '../../vue_shared/components/notes/placeholder_note. import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; +import { __ } from '~/locale'; import initUserPopovers from '../../user_popovers'; export default { @@ -71,6 +71,9 @@ export default { 'userCanReply', 'discussionTabCounter', ]), + discussionTabCounterText() { + return this.isLoading ? '' : this.discussionTabCounter; + }, noteableType() { return this.noteableData.noteableType; }, @@ -95,9 +98,9 @@ export default { this.fetchNotes(); } }, - allDiscussions() { - if (this.discussionsCount && !this.isLoading) { - this.discussionsCount.textContent = this.discussionTabCounter; + discussionTabCounterText(val) { + if (this.discussionsCount) { + this.discussionsCount.textContent = val; } }, }, diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index 3d89d907777..94ca01e44cc 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -35,20 +35,26 @@ export default { return false; }, - jumpToDiscussion(id) { + + switchToDiscussionsTabAndJumpTo(id) { + window.mrTabs.eventHub.$once('MergeRequestTabChange', () => { + setTimeout(() => this.discussionJump(id), 0); + }); + + window.mrTabs.tabShown('show'); + }, + + jumpToDiscussion(discussion) { + const { id, diff_discussion: isDiffDiscussion } = discussion; if (id) { const activeTab = window.mrTabs.currentAction; - if (activeTab === 'diffs') { + if (activeTab === 'diffs' && isDiffDiscussion) { this.diffsJump(id); - } else if (activeTab === 'commits' || activeTab === 'pipelines') { - window.mrTabs.eventHub.$once('MergeRequestTabChange', () => { - setTimeout(() => this.discussionJump(id), 0); - }); - - window.mrTabs.tabShown('show'); - } else { + } else if (activeTab === 'show') { this.discussionJump(id); + } else { + this.switchToDiscussionsTabAndJumpTo(id); } } }, diff --git a/app/assets/javascripts/notes/mixins/issuable_state.js b/app/assets/javascripts/notes/mixins/issuable_state.js index d97d9f6850a..0ca8c8c98a3 100644 --- a/app/assets/javascripts/notes/mixins/issuable_state.js +++ b/app/assets/javascripts/notes/mixins/issuable_state.js @@ -3,6 +3,12 @@ import { mapGetters } from 'vuex'; export default { computed: { ...mapGetters(['getNoteableDataByProp']), + isProjectArchived() { + return this.getNoteableDataByProp('is_project_archived'); + }, + archivedProjectDocsPath() { + return this.getNoteableDataByProp('archived_project_docs_path'); + }, lockedIssueDocsPath() { return this.getNoteableDataByProp('locked_discussion_docs_path'); }, diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 82c291379ec..9bd245c094d 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import $ from 'jquery'; -import axios from '~/lib/utils/axios_utils'; import Visibility from 'visibilityjs'; +import axios from '~/lib/utils/axios_utils'; import TaskList from '../../task_list'; import Flash from '../../flash'; import Poll from '../../lib/utils/poll'; @@ -14,7 +14,7 @@ import sidebarTimeTrackingEventHub from '../../sidebar/event_hub'; import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils'; import { mergeUrlParams } from '../../lib/utils/url_utility'; import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; import Api from '~/api'; let eTagPoll; @@ -252,29 +252,22 @@ export const saveNote = ({ commit, dispatch }, noteData) => { } } - const processErrors = res => { - const { errors } = res; - if (!errors || !Object.keys(errors).length) { - return res; - } - + const processQuickActions = res => { + const { errors: { commands_only: message } = { commands_only: null } } = res; /* The following reply means that quick actions have been successfully applied: {"commands_changes":{},"valid":false,"errors":{"commands_only":["Commands applied"]}} */ - if (hasQuickActions) { + if (hasQuickActions && message) { eTagPoll.makeRequest(); $('.js-gfm-input').trigger('clear-commands-cache.atwho'); - const { commands_only: message } = errors; Flash(message || __('Commands applied'), 'notice', noteData.flashContainer); - - return res; } - throw new Error(__('Failed to save comment!')); + return res; }; const processEmojiAward = res => { @@ -321,11 +314,33 @@ export const saveNote = ({ commit, dispatch }, noteData) => { return res; }; + const processErrors = error => { + if (error.response) { + const { + response: { data = {} }, + } = error; + const { errors = {} } = data; + const { base = [] } = errors; + + // we handle only errors.base for now + if (base.length > 0) { + const errorMsg = sprintf(__('Your comment could not be submitted because %{error}'), { + error: base[0].toLowerCase(), + }); + Flash(errorMsg, 'alert', noteData.flashContainer); + return { ...data, hasFlash: true }; + } + } + + throw error; + }; + return dispatch(methodToDispatch, postData, { root: true }) - .then(processErrors) + .then(processQuickActions) .then(processEmojiAward) .then(processTimeTracking) - .then(removePlaceholder); + .then(removePlaceholder) + .catch(processErrors); }; const pollSuccessCallBack = (resp, commit, state, getters, dispatch) => { diff --git a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue index 78aaa9df0ec..b43d6ba17d7 100644 --- a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue +++ b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue @@ -109,7 +109,7 @@ export default { <template> <p v-html="text"></p> <p v-html="confirmationTextLabel"></p> - <form ref="form" :action="deleteUserUrl" method="post"> + <form ref="form" :action="deleteUserUrl" method="post" @submit.prevent> <input ref="method" type="hidden" name="_method" value="delete" /> <input :value="csrfToken" type="hidden" name="authenticity_token" /> <gl-form-input diff --git a/app/assets/javascripts/pages/dashboard/merge_requests/index.js b/app/assets/javascripts/pages/dashboard/merge_requests/index.js index ff758fcb4fe..24d7b592948 100644 --- a/app/assets/javascripts/pages/dashboard/merge_requests/index.js +++ b/app/assets/javascripts/pages/dashboard/merge_requests/index.js @@ -1,6 +1,6 @@ +import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import projectSelect from '~/project_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; -import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; import { FILTERED_SEARCH } from '~/pages/constants'; diff --git a/app/assets/javascripts/pages/groups/group_members/index/index.js b/app/assets/javascripts/pages/groups/group_members/index/index.js index e4f4c3b574e..e77a7cf8e0a 100644 --- a/app/assets/javascripts/pages/groups/group_members/index/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index/index.js @@ -1,7 +1,7 @@ /* eslint-disable no-new */ -import memberExpirationDate from '~/member_expiration_date'; import Members from 'ee_else_ce/members'; +import memberExpirationDate from '~/member_expiration_date'; import UsersSelect from '~/users_select'; document.addEventListener('DOMContentLoaded', () => { diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index 090e1a2bc6d..4f15f5ec58c 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -1,9 +1,9 @@ +import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import initIssuablesList from '~/issuables_list'; import projectSelect from '~/project_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar'; import { FILTERED_SEARCH } from '~/pages/constants'; -import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import initManualOrdering from '~/manual_ordering'; const ISSUE_BULK_UPDATE_PREFIX = 'issue_'; diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js index 7520cfb6da0..13c5c350c24 100644 --- a/app/assets/javascripts/pages/groups/merge_requests/index.js +++ b/app/assets/javascripts/pages/groups/merge_requests/index.js @@ -1,8 +1,8 @@ +import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; 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_'; diff --git a/app/assets/javascripts/pages/groups/new/group_path_validator.js b/app/assets/javascripts/pages/groups/new/group_path_validator.js index 2021ad117e8..f1e7ff87e5a 100644 --- a/app/assets/javascripts/pages/groups/new/group_path_validator.js +++ b/app/assets/javascripts/pages/groups/new/group_path_validator.js @@ -1,6 +1,6 @@ +import _ from 'underscore'; import InputValidator from '~/validators/input_validator'; -import _ from 'underscore'; import fetchGroupPathAvailability from './fetch_group_path_availability'; import flash from '~/flash'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/pages/groups/registry/repositories/index.js b/app/assets/javascripts/pages/groups/registry/repositories/index.js index b663defad0e..635513afd95 100644 --- a/app/assets/javascripts/pages/groups/registry/repositories/index.js +++ b/app/assets/javascripts/pages/groups/registry/repositories/index.js @@ -1,3 +1,3 @@ -import initRegistryImages from '~/registry'; +import initRegistryImages from '~/registry/list'; document.addEventListener('DOMContentLoaded', initRegistryImages); diff --git a/app/assets/javascripts/pages/instance_statistics/conversational_development_index/index.js b/app/assets/javascripts/pages/instance_statistics/dev_ops_score/index.js index c1056537f90..c1056537f90 100644 --- a/app/assets/javascripts/pages/instance_statistics/conversational_development_index/index.js +++ b/app/assets/javascripts/pages/instance_statistics/dev_ops_score/index.js diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js index 13cb0d6f74b..ad003181728 100644 --- a/app/assets/javascripts/pages/profiles/show/index.js +++ b/app/assets/javascripts/pages/profiles/show/index.js @@ -1,7 +1,7 @@ import $ from 'jquery'; -import createFlash from '~/flash'; import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; import emojiRegex from 'emoji-regex'; +import createFlash from '~/flash'; import EmojiMenu from './emoji_menu'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/pages/projects/issues/form.js index 96e47187fed..34c7ee2e603 100644 --- a/app/assets/javascripts/pages/projects/issues/form.js +++ b/app/assets/javascripts/pages/projects/issues/form.js @@ -1,8 +1,8 @@ /* eslint-disable no-new */ import $ from 'jquery'; -import GLForm from '~/gl_form'; import IssuableForm from 'ee_else_ce/issuable_form'; +import GLForm from '~/gl_form'; import LabelsSelect from '~/labels_select'; import MilestoneSelect from '~/milestone_select'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js index c73ebb31eb3..bf54ca972b2 100644 --- a/app/assets/javascripts/pages/projects/issues/index/index.js +++ b/app/assets/javascripts/pages/projects/issues/index/index.js @@ -1,12 +1,12 @@ /* eslint-disable no-new */ +import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import IssuableIndex from '~/issuable_index'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import UsersSelect from '~/users_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import { FILTERED_SEARCH } from '~/pages/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants'; -import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import initManualOrdering from '~/manual_ordering'; document.addEventListener('DOMContentLoaded', () => { diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js index 0bcca22e40f..8f93cbb2a42 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js @@ -1,8 +1,8 @@ +import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import IssuableIndex from '~/issuable_index'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import UsersSelect from '~/users_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; -import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; import { FILTERED_SEARCH } from '~/pages/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants'; diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js index e51ab79a51d..76d72efb11b 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js @@ -1,10 +1,10 @@ /* eslint-disable no-new */ import $ from 'jquery'; +import IssuableForm from 'ee_else_ce/issuable_form'; import Diff from '~/diff'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import GLForm from '~/gl_form'; -import IssuableForm from 'ee_else_ce/issuable_form'; import LabelsSelect from '~/labels_select'; import MilestoneSelect from '~/milestone_select'; import IssuableTemplateSelectors from '~/templates/issuable_template_selectors'; diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js index 16034313af2..1f8befc07c8 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js @@ -6,6 +6,7 @@ import howToMerge from '~/how_to_merge'; import initPipelines from '~/commit/pipelines/pipelines_bundle'; import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle'; import initSourcegraph from '~/sourcegraph'; +import initPopover from '~/mr_tabs_popover'; import initWidget from '../../../vue_merge_request_widget'; export default function() { @@ -21,4 +22,10 @@ export default function() { howToMerge(); initWidget(); initSourcegraph(); + + const tabHighlightEl = document.querySelector('.js-tabs-feature-highlight'); + + if (tabHighlightEl) { + initPopover(tabHighlightEl); + } } diff --git a/app/assets/javascripts/pages/projects/pages_domains/edit/index.js b/app/assets/javascripts/pages/projects/pages_domains/show/index.js index 27e4433ad4d..27e4433ad4d 100644 --- a/app/assets/javascripts/pages/projects/pages_domains/edit/index.js +++ b/app/assets/javascripts/pages/projects/pages_domains/show/index.js diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index 01acfca158f..739ae1cea16 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, no-return-assign */ +/* eslint-disable func-names, no-return-assign */ import $ from 'jquery'; import Cookies from 'js-cookie'; @@ -90,19 +90,19 @@ export default class Project { } static initRefSwitcher() { - var refListItem = document.createElement('li'); - var refLink = document.createElement('a'); + const refListItem = document.createElement('li'); + const refLink = document.createElement('a'); refLink.href = '#'; return $('.js-project-refs-dropdown').each(function() { - var $dropdown = $(this); - var selected = $dropdown.data('selected'); - var fieldName = $dropdown.data('fieldName'); - var shouldVisit = Boolean($dropdown.data('visit')); - var $form = $dropdown.closest('form'); - var action = $form.attr('action'); - var linkTarget = mergeUrlParams(serializeForm($form[0]), action); + const $dropdown = $(this); + const selected = $dropdown.data('selected'); + const fieldName = $dropdown.data('fieldName'); + const shouldVisit = Boolean($dropdown.data('visit')); + const $form = $dropdown.closest('form'); + const action = $form.attr('action'); + const linkTarget = mergeUrlParams(serializeForm($form[0]), action); return $dropdown.glDropdown({ data(term, callback) { @@ -123,9 +123,9 @@ export default class Project { inputFieldName: $dropdown.data('inputFieldName'), fieldName, renderRow(ref) { - var li = refListItem.cloneNode(false); + const li = refListItem.cloneNode(false); - var link = refLink.cloneNode(false); + const link = refLink.cloneNode(false); if (ref === selected) { link.className = 'is-active'; diff --git a/app/assets/javascripts/pages/projects/registry/repositories/index.js b/app/assets/javascripts/pages/projects/registry/repositories/index.js index 35564754ee0..59310b3f76f 100644 --- a/app/assets/javascripts/pages/projects/registry/repositories/index.js +++ b/app/assets/javascripts/pages/projects/registry/repositories/index.js @@ -1,3 +1,3 @@ -import initRegistryImages from '~/registry/index'; +import initRegistryImages from '~/registry/list/index'; document.addEventListener('DOMContentLoaded', initRegistryImages); diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index 885247335a4..b4aac8eea2b 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -1,6 +1,7 @@ import initSettingsPanels from '~/settings_panels'; import SecretValues from '~/behaviors/secret_values'; import AjaxVariableList from '~/ci_variable_list/ajax_variable_list'; +import registrySettingsApp from '~/registry/settings/registry_settings_bundle'; document.addEventListener('DOMContentLoaded', () => { // Initialize expandable settings panels @@ -32,4 +33,6 @@ document.addEventListener('DOMContentLoaded', () => { if (instanceDefaultBadge) instanceDefaultBadge.style.display = 'none'; autoDevOpsExtraSettings.classList.toggle('hidden', !target.checked); }); + + registrySettingsApp(); }); diff --git a/app/assets/javascripts/pages/projects/snippets/show/index.js b/app/assets/javascripts/pages/projects/snippets/show/index.js index c35b9c30058..738bf08f1bf 100644 --- a/app/assets/javascripts/pages/projects/snippets/show/index.js +++ b/app/assets/javascripts/pages/projects/snippets/show/index.js @@ -3,11 +3,16 @@ import ZenMode from '~/zen_mode'; import LineHighlighter from '~/line_highlighter'; import BlobViewer from '~/blob/viewer'; import snippetEmbed from '~/snippet/snippet_embed'; +import initSnippetsApp from '~/snippets'; document.addEventListener('DOMContentLoaded', () => { - new LineHighlighter(); // eslint-disable-line no-new - new BlobViewer(); // eslint-disable-line no-new - initNotes(); - new ZenMode(); // eslint-disable-line no-new - snippetEmbed(); + if (!gon.features.snippetsVue) { + new LineHighlighter(); // eslint-disable-line no-new + new BlobViewer(); // eslint-disable-line no-new + initNotes(); + new ZenMode(); // eslint-disable-line no-new + snippetEmbed(); + } else { + initSnippetsApp(); + } }); diff --git a/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue b/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue index b0c9ca3ec0d..2176309ac84 100644 --- a/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue +++ b/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue @@ -1,7 +1,7 @@ <script> import _ from 'underscore'; -import { s__, sprintf } from '~/locale'; import { GlModal, GlModalDirective } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; export default { components: { diff --git a/app/assets/javascripts/pages/sessions/new/username_validator.js b/app/assets/javascripts/pages/sessions/new/username_validator.js index 36d1e773134..25be71d9ed4 100644 --- a/app/assets/javascripts/pages/sessions/new/username_validator.js +++ b/app/assets/javascripts/pages/sessions/new/username_validator.js @@ -1,6 +1,6 @@ +import _ from 'underscore'; import InputValidator from '~/validators/input_validator'; -import _ from 'underscore'; import axios from '~/lib/utils/axios_utils'; import flash from '~/flash'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/pages/snippets/show/index.js b/app/assets/javascripts/pages/snippets/show/index.js index 26936110402..6e00c14f43e 100644 --- a/app/assets/javascripts/pages/snippets/show/index.js +++ b/app/assets/javascripts/pages/snippets/show/index.js @@ -3,11 +3,16 @@ import BlobViewer from '~/blob/viewer'; import ZenMode from '~/zen_mode'; import initNotes from '~/init_notes'; import snippetEmbed from '~/snippet/snippet_embed'; +import initSnippetsApp from '~/snippets'; document.addEventListener('DOMContentLoaded', () => { - new LineHighlighter(); // eslint-disable-line no-new - new BlobViewer(); // eslint-disable-line no-new - initNotes(); - new ZenMode(); // eslint-disable-line no-new - snippetEmbed(); + if (!gon.features.snippetsVue) { + new LineHighlighter(); // eslint-disable-line no-new + new BlobViewer(); // eslint-disable-line no-new + initNotes(); + new ZenMode(); // eslint-disable-line no-new + snippetEmbed(); + } else { + initSnippetsApp(); + } }); diff --git a/app/assets/javascripts/pages/users/index.js b/app/assets/javascripts/pages/users/index.js index a191df00dfa..cfc6dc61f9f 100644 --- a/app/assets/javascripts/pages/users/index.js +++ b/app/assets/javascripts/pages/users/index.js @@ -1,6 +1,6 @@ import $ from 'jquery'; -import UserCallout from '~/user_callout'; import Cookies from 'js-cookie'; +import UserCallout from '~/user_callout'; import UserTabs from './user_tabs'; function initUserProfile(action) { 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 8ce653bf1fb..d17c2f33adc 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -52,6 +52,11 @@ export default { header: s__('PerformanceBar|Redis calls'), keys: ['cmd'], }, + { + metric: 'total', + header: s__('PerformanceBar|Frontend resources'), + keys: ['name', 'size'], + }, ], data() { return { currentRequestId: '' }; @@ -80,6 +85,15 @@ export default { } return ''; }, + downloadPath() { + const data = JSON.stringify(this.requests); + const blob = new Blob([data], { type: 'text/plain' }); + return window.URL.createObjectURL(blob); + }, + downloadName() { + const fileName = this.requests[0].truncatedUrl; + return `${fileName}_perf_bar_${Date.now()}.json`; + }, }, mounted() { this.currentRequest = this.requestId; @@ -121,6 +135,9 @@ export default { <a :href="currentRequest.details.tracing.tracing_url">{{ s__('PerformanceBar|trace') }}</a> </div> <add-request v-on="$listeners" /> + <div v-if="currentRequest.details" id="peek-download" class="view"> + <a :download="downloadName" :href="downloadPath">{{ s__('PerformanceBar|Download') }}</a> + </div> <request-selector v-if="currentRequest" :current-request="currentRequest" diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue index 793aba3189b..1610534ae0d 100644 --- a/app/assets/javascripts/performance_bar/components/request_selector.vue +++ b/app/assets/javascripts/performance_bar/components/request_selector.vue @@ -1,7 +1,7 @@ <script> +import { GlPopover } from '@gitlab/ui'; import { glEmojiTag } from '~/emoji'; import { n__ } from '~/locale'; -import { GlPopover } from '@gitlab/ui'; export default { components: { @@ -40,16 +40,6 @@ export default { }, }, methods: { - truncatedUrl(requestUrl) { - const components = requestUrl.replace(/\/$/, '').split('/'); - let truncated = components[components.length - 1]; - - if (truncated.match(/^\d+$/)) { - truncated = `${components[components.length - 2]}/${truncated}`; - } - - return truncated; - }, glEmojiTag, }, }; @@ -63,7 +53,7 @@ export default { :value="request.id" class="qa-performance-bar-request" > - {{ truncatedUrl(request.url) }} + {{ request.truncatedUrl }} <span v-if="request.hasWarnings">(!)</span> </option> </select> diff --git a/app/assets/javascripts/performance_bar/components/request_warning.vue b/app/assets/javascripts/performance_bar/components/request_warning.vue index 0da3c271214..0128d5bd733 100644 --- a/app/assets/javascripts/performance_bar/components/request_warning.vue +++ b/app/assets/javascripts/performance_bar/components/request_warning.vue @@ -1,6 +1,6 @@ <script> -import { glEmojiTag } from '~/emoji'; import { GlPopover } from '@gitlab/ui'; +import { glEmojiTag } from '~/emoji'; export default { components: { diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js index 735c9d804ee..7b373a8ce22 100644 --- a/app/assets/javascripts/performance_bar/index.js +++ b/app/assets/javascripts/performance_bar/index.js @@ -1,3 +1,4 @@ +/* eslint-disable @gitlab/i18n/no-non-i18n-strings */ import Vue from 'vue'; import axios from '~/lib/utils/axios_utils'; @@ -53,12 +54,57 @@ export default ({ container }) => PerformanceBarService.fetchRequestDetails(this.peekUrl, requestId) .then(res => { this.store.addRequestDetails(requestId, res.data); + + if (this.requestId === requestId) this.collectFrontendPerformanceMetrics(); }) .catch(() => // eslint-disable-next-line no-console console.warn(`Error getting performance bar results for ${requestId}`), ); }, + collectFrontendPerformanceMetrics() { + if (performance) { + const navigationEntries = performance.getEntriesByType('navigation'); + const paintEntries = performance.getEntriesByType('paint'); + const resourceEntries = performance.getEntriesByType('resource'); + + let durationString = ''; + if (navigationEntries.length > 0) { + durationString = `${Math.round(navigationEntries[0].responseEnd)} | `; + durationString += `${Math.round(paintEntries[1].startTime)} | `; + durationString += ` ${Math.round(navigationEntries[0].domContentLoadedEventEnd)}`; + } + + let newEntries = resourceEntries.map(this.transformResourceEntry); + + this.updateFrontendPerformanceMetrics(durationString, newEntries); + + if ('PerformanceObserver' in window) { + // We start observing for more incoming timings + const observer = new PerformanceObserver(list => { + newEntries = newEntries.concat(list.getEntries().map(this.transformResourceEntry)); + this.updateFrontendPerformanceMetrics(durationString, newEntries); + }); + + observer.observe({ entryTypes: ['resource'] }); + } + } + }, + updateFrontendPerformanceMetrics(durationString, requestEntries) { + this.store.setRequestDetailsData(this.requestId, 'total', { + duration: durationString, + calls: requestEntries.length, + details: requestEntries, + }); + }, + transformResourceEntry(entry) { + const nf = new Intl.NumberFormat(); + return { + name: entry.name.replace(document.location.origin, ''), + duration: Math.round(entry.duration), + size: entry.transferSize ? `${nf.format(entry.transferSize)} bytes` : 'cached', + }; + }, }, render(createElement) { return createElement('performance-bar-app', { diff --git a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js index 64f4f5e0c76..6f443db47ed 100644 --- a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js +++ b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js @@ -5,9 +5,12 @@ export default class PerformanceBarStore { addRequest(requestId, requestUrl) { if (!this.findRequest(requestId)) { + const shortUrl = PerformanceBarStore.truncateUrl(requestUrl); + this.requests.push({ id: requestId, url: requestUrl, + truncatedUrl: shortUrl, details: {}, hasWarnings: false, }); @@ -29,6 +32,16 @@ export default class PerformanceBarStore { return request; } + setRequestDetailsData(requestId, metricKey, requestDetailsData) { + const selectedRequest = this.findRequest(requestId); + if (selectedRequest) { + selectedRequest.details = { + ...selectedRequest.details, + [metricKey]: requestDetailsData, + }; + } + } + requestsWithDetails() { return this.requests.filter(request => request.details); } @@ -36,4 +49,20 @@ export default class PerformanceBarStore { canTrackRequest(requestUrl) { return this.requests.filter(request => request.url === requestUrl).length < 2; } + + static truncateUrl(requestUrl) { + const [rootAndQuery] = requestUrl.split('#'); + const [root, query] = rootAndQuery.split('?'); + const components = root.replace(/\/$/, '').split('/'); + + let truncated = components[components.length - 1]; + if (truncated.match(/^\d+$/)) { + truncated = `${components[components.length - 2]}/${truncated}`; + } + if (query) { + truncated += `?${query}`; + } + + return truncated; + } } diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js index 8d6a3781048..4598626718c 100644 --- a/app/assets/javascripts/persistent_user_callout.js +++ b/app/assets/javascripts/persistent_user_callout.js @@ -6,8 +6,8 @@ import Flash from './flash'; const DEFERRED_LINK_CLASS = 'deferred-link'; export default class PersistentUserCallout { - constructor(container) { - const { dismissEndpoint, featureId, deferLinks } = container.dataset; + constructor(container, options = container.dataset) { + const { dismissEndpoint, featureId, deferLinks } = options; this.container = container; this.dismissEndpoint = dismissEndpoint; this.featureId = featureId; @@ -53,11 +53,11 @@ export default class PersistentUserCallout { }); } - static factory(container) { + static factory(container, options) { if (!container) { return undefined; } - return new PersistentUserCallout(container); + return new PersistentUserCallout(container, options); } } diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index e29509ce074..429122c8083 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -100,9 +100,6 @@ export default { hasOnlyOneJob(stage) { return stage.groups.length === 1; }, - hasDownstream(index, length) { - return index === length - 1 && this.hasTriggered; - }, hasUpstream(index) { return index === 0 && this.hasTriggeredBy; }, @@ -160,7 +157,6 @@ export default { :key="stage.name" :class="{ 'has-upstream prepend-left-64': hasUpstream(index), - 'has-downstream': hasDownstream(index, graph.length), 'has-only-one-job': hasOnlyOneJob(stage), 'append-right-46': shouldAddRightMargin(index), }" diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 0d5afe04e8e..bfd314e0439 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -1,7 +1,7 @@ <script> +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; import ActionComponent from './action_component.vue'; import JobNameComponent from './job_name_component.vue'; -import { GlTooltipDirective, GlLink } from '@gitlab/ui'; import { sprintf } from '~/locale'; import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; @@ -111,7 +111,7 @@ export default { :href="status.details_path" :title="tooltipText" :class="cssClassJobName" - class="js-pipeline-graph-job-link qa-job-link" + class="js-pipeline-graph-job-link qa-job-link menu-item" > <job-name-component :name="job.name" :status="job.status" /> </gl-link> diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index 4e7d77863b9..82335e71403 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -42,7 +42,6 @@ export default { <template> <li class="linked-pipeline build"> - <div class="curve"></div> <gl-button :id="buttonId" v-gl-tooltip diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue index 6efdde2b17e..998519f9df1 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -1,5 +1,6 @@ <script> import LinkedPipeline from './linked_pipeline.vue'; +import { __ } from '~/locale'; export default { components: { @@ -27,6 +28,9 @@ export default { }; return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`; }, + isUpstream() { + return this.columnTitle === __('Upstream'); + }, }, }; </script> @@ -34,13 +38,12 @@ export default { <template> <div :class="columnClass" class="stage-column linked-pipelines-column"> <div class="stage-name linked-pipelines-column-title">{{ columnTitle }}</div> - <div class="cross-project-triangle"></div> + <div v-if="isUpstream" class="cross-project-triangle"></div> <ul> <linked-pipeline v-for="(pipeline, index) in linkedPipelines" :key="pipeline.id" :class="{ - 'flat-connector-before': index === 0 && graphPosition === 'right', active: pipeline.isExpanded, 'left-connector': pipeline.isExpanded && graphPosition === 'left', }" diff --git a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue index 2e71b3c637b..7c4e651373f 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue @@ -1,7 +1,7 @@ <script> import _ from 'underscore'; -import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import { GlLink } from '@gitlab/ui'; +import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { s__, sprintf } from '~/locale'; diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue index dce8b020d6f..1bac7ce9ac5 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue @@ -28,7 +28,9 @@ export default { return this.report.name || __('Summary'); }, successPercentage() { - return Math.round((this.report.success_count / this.report.total_count) * 100) || 0; + // Returns a full number when the decimals equal .00. + // Otherwise returns a float to two decimal points + return Number(((this.report.success_count / this.report.total_count) * 100 || 0).toFixed(2)); }, formattedDuration() { return formatTime(secondsToMilliseconds(this.report.total_time)); diff --git a/app/assets/javascripts/pipelines/components/time_ago.vue b/app/assets/javascripts/pipelines/components/time_ago.vue index 2ed0c24825c..2a23a0f6744 100644 --- a/app/assets/javascripts/pipelines/components/time_ago.vue +++ b/app/assets/javascripts/pipelines/components/time_ago.vue @@ -31,7 +31,7 @@ export default { hasFinishedTime() { return this.finishedTime !== ''; }, - durationFormated() { + durationFormatted() { const date = new Date(this.duration * 1000); let hh = date.getUTCHours(); @@ -59,7 +59,7 @@ export default { <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Duration') }}</div> <div class="table-mobile-content"> <p v-if="hasDuration" class="duration"> - <span v-html="iconTimerSvg"> </span> {{ durationFormated }} + <span v-html="iconTimerSvg"> </span> {{ durationFormatted }} </p> <p v-if="hasFinishedTime" class="finished-at d-none d-sm-none d-md-block"> @@ -71,7 +71,7 @@ export default { data-placement="top" data-container="body" > - {{ timeFormated(finishedTime) }} + {{ timeFormatted(finishedTime) }} </time> </p> </div> diff --git a/app/assets/javascripts/pipelines/stores/test_reports/utils.js b/app/assets/javascripts/pipelines/stores/test_reports/utils.js index 95466587d6b..16fa6935cbe 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/utils.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/utils.js @@ -1,7 +1,7 @@ import { TestStatus } from '~/pipelines/constants'; import { formatTime, secondsToMilliseconds } from '~/lib/utils/datetime_utility'; -function iconForTestStatus(status) { +export function iconForTestStatus(status) { switch (status) { case 'success': return 'status_success_borderless'; diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js index 031c54d2336..d6cdd37a2c3 100644 --- a/app/assets/javascripts/project_find_file.js +++ b/app/assets/javascripts/project_find_file.js @@ -2,10 +2,11 @@ import $ from 'jquery'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import sanitize from 'sanitize-html'; import axios from '~/lib/utils/axios_utils'; +import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; import flash from '~/flash'; import { __ } from '~/locale'; -import sanitize from 'sanitize-html'; // highlight text(awefwbwgtc -> <b>a</b>wefw<b>b</b>wgt<b>c</b> ) const highlighter = function(element, text, matches) { @@ -116,7 +117,7 @@ export default class ProjectFindFile { if (searchText) { matches = fuzzaldrinPlus.match(filePath, searchText); } - const blobItemUrl = `${this.options.blobUrlTemplate}/${encodeURIComponent(filePath)}`; + const blobItemUrl = joinPaths(this.options.blobUrlTemplate, escapeFileUrl(filePath)); const html = ProjectFindFile.makeHtml(filePath, matches, blobItemUrl); results.push(this.element.find('.tree-table > tbody').append(html)); } diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 2429da9c061..92c4c05bd87 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -128,15 +128,15 @@ const bindEvents = () => { }, iosswift: { text: s__('ProjectTemplates|iOS (Swift)'), - icon: '.template-option svg.icon-gitlab', + icon: '.template-option .icon-iosswift', }, dotnetcore: { text: s__('ProjectTemplates|.NET Core'), - icon: '.template-option .icon-dotnet', + icon: '.template-option .icon-dotnetcore', }, android: { text: s__('ProjectTemplates|Android'), - icon: '.template-option svg.icon-android', + icon: '.template-option .icon-android', }, gomicro: { text: s__('ProjectTemplates|Go Micro'), @@ -164,23 +164,27 @@ const bindEvents = () => { }, nfhugo: { text: s__('ProjectTemplates|Netlify/Hugo'), - icon: '.template-option .icon-netlify', + icon: '.template-option .icon-nfhugo', }, nfjekyll: { text: s__('ProjectTemplates|Netlify/Jekyll'), - icon: '.template-option .icon-netlify', + icon: '.template-option .icon-nfjekyll', }, nfplainhtml: { text: s__('ProjectTemplates|Netlify/Plain HTML'), - icon: '.template-option .icon-netlify', + icon: '.template-option .icon-nfplainhtml', }, nfgitbook: { text: s__('ProjectTemplates|Netlify/GitBook'), - icon: '.template-option .icon-netlify', + icon: '.template-option .icon-nfgitbook', }, nfhexo: { text: s__('ProjectTemplates|Netlify/Hexo'), - icon: '.template-option .icon-netlify', + icon: '.template-option .icon-nfhexo', + }, + salesforcedx: { + text: s__('ProjectTemplates|SalesforceDX'), + icon: '.template-option .icon-salesforcedx', }, serverless_framework: { text: s__('ProjectTemplates|Serverless Framework/JS'), 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 60fd3ed5ea7..f1106dc6ae9 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 @@ -1,11 +1,11 @@ <script> import Visibility from 'visibilityjs'; +import { GlLoadingIcon } from '@gitlab/ui'; import ciIcon from '~/vue_shared/components/ci_icon.vue'; import Poll from '~/lib/utils/poll'; import Flash from '~/flash'; import { __, s__, sprintf } from '~/locale'; import tooltip from '~/vue_shared/directives/tooltip'; -import { GlLoadingIcon } from '@gitlab/ui'; import CommitPipelineService from '../services/commit_pipeline_service'; export default { diff --git a/app/assets/javascripts/registry/components/app.vue b/app/assets/javascripts/registry/list/components/app.vue index 11b2c3b7016..c555c2b04d1 100644 --- a/app/assets/javascripts/registry/components/app.vue +++ b/app/assets/javascripts/registry/list/components/app.vue @@ -5,7 +5,7 @@ import store from '../stores'; import CollapsibleContainer from './collapsible_container.vue'; import ProjectEmptyState from './project_empty_state.vue'; import GroupEmptyState from './group_empty_state.vue'; -import { s__, sprintf } from '../../locale'; +import { s__, sprintf } from '~/locale'; export default { name: 'RegistryListApp', diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/list/components/collapsible_container.vue index 5a6f9370564..86bb2d8092e 100644 --- a/app/assets/javascripts/registry/components/collapsible_container.vue +++ b/app/assets/javascripts/registry/list/components/collapsible_container.vue @@ -31,7 +31,7 @@ export default { GlTooltip: GlTooltipDirective, GlModal: GlModalDirective, }, - mixins: [Tracking.mixin({})], + mixins: [Tracking.mixin()], props: { repo: { type: Object, @@ -43,7 +43,6 @@ export default { isOpen: false, modalId: `confirm-repo-deletion-modal-${this.repo.id}`, tracking: { - category: document.body.dataset.page, label: 'registry_repository_delete', }, }; @@ -67,7 +66,7 @@ export default { } }, handleDeleteRepository() { - this.track('confirm_delete', {}); + this.track('confirm_delete'); return this.deleteItem(this.repo) .then(() => { createFlash(__('This container registry has been scheduled for deletion.'), 'notice'); @@ -103,7 +102,7 @@ export default { :aria-label="s__('ContainerRegistry|Remove repository')" class="js-remove-repo btn-inverted" variant="danger" - @click="track('click_button', {})" + @click="track('click_button')" > <icon name="remove" /> </gl-button> @@ -132,7 +131,7 @@ export default { :modal-id="modalId" ok-variant="danger" @ok="handleDeleteRepository" - @cancel="track('cancel_delete', {})" + @cancel="track('cancel_delete')" > <template v-slot:modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template> <p diff --git a/app/assets/javascripts/registry/components/group_empty_state.vue b/app/assets/javascripts/registry/list/components/group_empty_state.vue index 7885fd2146d..7885fd2146d 100644 --- a/app/assets/javascripts/registry/components/group_empty_state.vue +++ b/app/assets/javascripts/registry/list/components/group_empty_state.vue diff --git a/app/assets/javascripts/registry/components/project_empty_state.vue b/app/assets/javascripts/registry/list/components/project_empty_state.vue index 80ef31004c8..80ef31004c8 100644 --- a/app/assets/javascripts/registry/components/project_empty_state.vue +++ b/app/assets/javascripts/registry/list/components/project_empty_state.vue diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/list/components/table_registry.vue index caa5fd4ff4e..4e14db7f578 100644 --- a/app/assets/javascripts/registry/components/table_registry.vue +++ b/app/assets/javascripts/registry/list/components/table_registry.vue @@ -23,7 +23,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [timeagoMixin], + mixins: [timeagoMixin, Tracking.mixin()], props: { repo: { type: Object, @@ -71,9 +71,6 @@ export default { }, methods: { ...mapActions(['fetchList', 'deleteItem', 'multiDeleteItems']), - track(action) { - Tracking.event(document.body.dataset.page, action, this.tracking); - }, setModalDescription(itemIndex = -1) { if (itemIndex === -1) { this.modalDescription = sprintf( @@ -196,7 +193,7 @@ export default { /> </th> <th>{{ s__('ContainerRegistry|Tag') }}</th> - <th>{{ s__('ContainerRegistry|Tag ID') }}</th> + <th ref="imageId">{{ s__('ContainerRegistry|Image ID') }}</th> <th>{{ s__('ContainerRegistry|Size') }}</th> <th>{{ s__('ContainerRegistry|Last Updated') }}</th> <th> @@ -250,7 +247,7 @@ export default { <td> <span v-gl-tooltip.bottom :title="tooltipTitle(item.createdAt)">{{ - timeFormated(item.createdAt) + timeFormatted(item.createdAt) }}</span> </td> diff --git a/app/assets/javascripts/registry/constants.js b/app/assets/javascripts/registry/list/constants.js index db798fb88ac..e55ea9cc9d9 100644 --- a/app/assets/javascripts/registry/constants.js +++ b/app/assets/javascripts/registry/list/constants.js @@ -1,4 +1,4 @@ -import { __ } from '../locale'; +import { __ } from '~/locale'; export const FETCH_REGISTRY_ERROR_MESSAGE = __( 'Something went wrong while fetching the registry list.', diff --git a/app/assets/javascripts/registry/index.js b/app/assets/javascripts/registry/list/index.js index 18fd360f586..3d0ff327b42 100644 --- a/app/assets/javascripts/registry/index.js +++ b/app/assets/javascripts/registry/list/index.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import registryApp from './components/app.vue'; -import Translate from '../vue_shared/translate'; +import Translate from '~/vue_shared/translate'; Vue.use(Translate); diff --git a/app/assets/javascripts/registry/stores/actions.js b/app/assets/javascripts/registry/list/stores/actions.js index 6afba618486..6afba618486 100644 --- a/app/assets/javascripts/registry/stores/actions.js +++ b/app/assets/javascripts/registry/list/stores/actions.js diff --git a/app/assets/javascripts/registry/stores/getters.js b/app/assets/javascripts/registry/list/stores/getters.js index ac90bde1b2a..ac90bde1b2a 100644 --- a/app/assets/javascripts/registry/stores/getters.js +++ b/app/assets/javascripts/registry/list/stores/getters.js diff --git a/app/assets/javascripts/registry/stores/index.js b/app/assets/javascripts/registry/list/stores/index.js index 1bb06bd6e81..1bb06bd6e81 100644 --- a/app/assets/javascripts/registry/stores/index.js +++ b/app/assets/javascripts/registry/list/stores/index.js diff --git a/app/assets/javascripts/registry/stores/mutation_types.js b/app/assets/javascripts/registry/list/stores/mutation_types.js index 6740bfede1a..6740bfede1a 100644 --- a/app/assets/javascripts/registry/stores/mutation_types.js +++ b/app/assets/javascripts/registry/list/stores/mutation_types.js diff --git a/app/assets/javascripts/registry/stores/mutations.js b/app/assets/javascripts/registry/list/stores/mutations.js index 419de848883..419de848883 100644 --- a/app/assets/javascripts/registry/stores/mutations.js +++ b/app/assets/javascripts/registry/list/stores/mutations.js diff --git a/app/assets/javascripts/registry/stores/state.js b/app/assets/javascripts/registry/list/stores/state.js index 724c64b4994..724c64b4994 100644 --- a/app/assets/javascripts/registry/stores/state.js +++ b/app/assets/javascripts/registry/list/stores/state.js diff --git a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue new file mode 100644 index 00000000000..b2c700b817c --- /dev/null +++ b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue @@ -0,0 +1,43 @@ +<script> +import { mapState } from 'vuex'; +import { s__, sprintf } from '~/locale'; + +export default { + components: {}, + computed: { + ...mapState({ + helpPagePath: 'helpPagePath', + }), + + helpText() { + return sprintf( + s__( + 'PackageRegistry|Read more about the %{helpLinkStart}Container Registry tag retention policies%{helpLinkEnd}', + ), + { + helpLinkStart: `<a href="${this.helpPagePath}" target="_blank">`, + helpLinkEnd: '</a>', + }, + false, + ); + }, + }, +}; +</script> + +<template> + <div> + <p> + {{ s__('PackageRegistry|Tag retention policies are designed to:') }} + </p> + <ul> + <li>{{ s__('PackageRegistry|Keep and protect the images that matter most.') }}</li> + <li> + {{ + s__("PackageRegistry|Automatically remove extra images that aren't designed to be kept.") + }} + </li> + </ul> + <p ref="help-link" v-html="helpText"></p> + </div> +</template> diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/registry/settings/registry_settings_bundle.js new file mode 100644 index 00000000000..2938178ea86 --- /dev/null +++ b/app/assets/javascripts/registry/settings/registry_settings_bundle.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import store from './stores/'; +import RegistrySettingsApp from './components/registry_settings_app.vue'; + +Vue.use(Translate); + +export default () => { + const el = document.getElementById('js-registry-settings'); + if (!el) { + return null; + } + store.dispatch('setInitialState', el.dataset); + return new Vue({ + el, + store, + components: { + RegistrySettingsApp, + }, + render(createElement) { + return createElement('registry-settings-app', {}); + }, + }); +}; diff --git a/app/assets/javascripts/registry/settings/stores/actions.js b/app/assets/javascripts/registry/settings/stores/actions.js new file mode 100644 index 00000000000..f2c469d4edb --- /dev/null +++ b/app/assets/javascripts/registry/settings/stores/actions.js @@ -0,0 +1,6 @@ +import * as types from './mutation_types'; + +export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data); + +// to avoid eslint error until more actions are added to the store +export default () => {}; diff --git a/app/assets/javascripts/registry/settings/stores/index.js b/app/assets/javascripts/registry/settings/stores/index.js new file mode 100644 index 00000000000..91a35aac149 --- /dev/null +++ b/app/assets/javascripts/registry/settings/stores/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + state, + actions, + mutations, + }); + +export default createStore(); diff --git a/app/assets/javascripts/registry/settings/stores/mutation_types.js b/app/assets/javascripts/registry/settings/stores/mutation_types.js new file mode 100644 index 00000000000..8a0f519eabd --- /dev/null +++ b/app/assets/javascripts/registry/settings/stores/mutation_types.js @@ -0,0 +1,4 @@ +export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; + +// to avoid eslint error until more actions are added to the store +export default () => {}; diff --git a/app/assets/javascripts/registry/settings/stores/mutations.js b/app/assets/javascripts/registry/settings/stores/mutations.js new file mode 100644 index 00000000000..4f32e11ed52 --- /dev/null +++ b/app/assets/javascripts/registry/settings/stores/mutations.js @@ -0,0 +1,8 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_INITIAL_STATE](state, initialState) { + state.helpPagePath = initialState.helpPagePath; + state.registrySettingsEndpoint = initialState.registrySettingsEndpoint; + }, +}; diff --git a/app/assets/javascripts/registry/settings/stores/state.js b/app/assets/javascripts/registry/settings/stores/state.js new file mode 100644 index 00000000000..4c0439458b6 --- /dev/null +++ b/app/assets/javascripts/registry/settings/stores/state.js @@ -0,0 +1,10 @@ +export default () => ({ + /* + * Help page path to generate the link + */ + helpPagePath: '', + /* + * Settings endpoint to call to fetch and update the settings + */ + registrySettingsEndpoint: '', +}); 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 f0112a5a623..dc7c9d9f174 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 @@ -72,7 +72,7 @@ export default { {{ __('Related merge requests') }} </span> <div v-if="totalCount" class="d-inline-flex lh-100 align-middle"> - <div class="mr-count-badge"> + <div class="mr-count-badge border-width-1px border-style-solid border-color-default"> <div class="mr-count-badge-count"> <svg class="s16 mr-1 text-secondary"> <icon name="merge-request" class="mr-1 text-secondary" /> diff --git a/app/assets/javascripts/releases/list/components/app.vue b/app/assets/javascripts/releases/list/components/app.vue index 5a06c4fec58..a414b3ccd4e 100644 --- a/app/assets/javascripts/releases/list/components/app.vue +++ b/app/assets/javascripts/releases/list/components/app.vue @@ -1,6 +1,12 @@ <script> import { mapState, mapActions } from 'vuex'; import { GlSkeletonLoading, GlEmptyState } from '@gitlab/ui'; +import { + getParameterByName, + historyPushState, + buildUrlWithCurrentLocation, +} from '~/lib/utils/common_utils'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; import ReleaseBlock from './release_block.vue'; export default { @@ -9,6 +15,7 @@ export default { GlSkeletonLoading, GlEmptyState, ReleaseBlock, + TablePagination, }, props: { projectId: { @@ -25,7 +32,7 @@ export default { }, }, computed: { - ...mapState(['isLoading', 'releases', 'hasError']), + ...mapState(['isLoading', 'releases', 'hasError', 'pageInfo']), shouldRenderEmptyState() { return !this.releases.length && !this.hasError && !this.isLoading; }, @@ -34,10 +41,17 @@ export default { }, }, created() { - this.fetchReleases(this.projectId); + this.fetchReleases({ + page: getParameterByName('page'), + projectId: this.projectId, + }); }, methods: { ...mapActions(['fetchReleases']), + onChangePage(page) { + historyPushState(buildUrlWithCurrentLocation(`?page=${page}`)); + this.fetchReleases({ page, projectId: this.projectId }); + }, }, }; </script> @@ -67,6 +81,8 @@ export default { :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }" /> </div> + + <table-pagination v-if="!isLoading" :change="onChangePage" :page-info="pageInfo" /> </div> </template> <style> diff --git a/app/assets/javascripts/releases/list/components/evidence_block.vue b/app/assets/javascripts/releases/list/components/evidence_block.vue new file mode 100644 index 00000000000..d9abd195fee --- /dev/null +++ b/app/assets/javascripts/releases/list/components/evidence_block.vue @@ -0,0 +1,76 @@ +<script> +import { GlLink, GlTooltipDirective } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import { truncateSha } from '~/lib/utils/text_utility'; +import Icon from '~/vue_shared/components/icon.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ExpandButton from '~/vue_shared/components/expand_button.vue'; + +export default { + name: 'EvidenceBlock', + components: { + ClipboardButton, + ExpandButton, + GlLink, + Icon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + release: { + type: Object, + required: true, + }, + }, + computed: { + evidenceTitle() { + return sprintf(__('%{tag}-evidence.json'), { tag: this.release.tag_name }); + }, + evidenceUrl() { + return this.release.assets && this.release.assets.evidence_file_path; + }, + shortSha() { + return truncateSha(this.sha); + }, + sha() { + return this.release.evidence_sha; + }, + }, +}; +</script> + +<template> + <div> + <div class="card-text prepend-top-default"> + <b> + {{ __('Evidence collection') }} + </b> + </div> + <div class="d-flex align-items-baseline"> + <gl-link + v-gl-tooltip + class="monospace" + :title="__('Download evidence JSON')" + :download="evidenceTitle" + :href="evidenceUrl" + > + <icon name="review-list" class="align-top append-right-4" /><span>{{ evidenceTitle }}</span> + </gl-link> + + <expand-button> + <template slot="short"> + <span class="js-short monospace">{{ shortSha }}</span> + </template> + <template slot="expanded"> + <span class="js-expanded monospace gl-pl-1">{{ sha }}</span> + </template> + </expand-button> + <clipboard-button + :title="__('Copy commit SHA')" + :text="sha" + css-class="btn-default btn-transparent btn-clipboard" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/releases/list/components/release_block.vue b/app/assets/javascripts/releases/list/components/release_block.vue index 2b6aa6aeff9..4d8d8682401 100644 --- a/app/assets/javascripts/releases/list/components/release_block.vue +++ b/app/assets/javascripts/releases/list/components/release_block.vue @@ -11,16 +11,20 @@ import { getLocationHash } from '~/lib/utils/url_utility'; import { scrollToElement } from '~/lib/utils/common_utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ReleaseBlockFooter from './release_block_footer.vue'; +import EvidenceBlock from './evidence_block.vue'; +import ReleaseBlockMilestoneInfo from './release_block_milestone_info.vue'; export default { name: 'ReleaseBlock', components: { + EvidenceBlock, GlLink, GlBadge, GlButton, Icon, UserAvatarLink, ReleaseBlockFooter, + ReleaseBlockMilestoneInfo, }, directives: { GlTooltip: GlTooltipDirective, @@ -44,7 +48,7 @@ export default { }, releasedTimeAgo() { return sprintf(__('released %{time}'), { - time: this.timeFormated(this.release.released_at), + time: this.timeFormatted(this.release.released_at), }); }, userImageAltDescription() { @@ -70,6 +74,9 @@ export default { hasAuthor() { return !_.isEmpty(this.author); }, + hasEvidence() { + return Boolean(this.release.evidence_sha); + }, shouldRenderMilestones() { return !_.isEmpty(this.release.milestones); }, @@ -77,13 +84,20 @@ export default { return n__('Milestone', 'Milestones', this.release.milestones.length); }, shouldShowEditButton() { - return Boolean( - this.glFeatures.releaseEditPage && this.release._links && this.release._links.edit_url, - ); + return Boolean(this.release._links && this.release._links.edit_url); + }, + shouldShowEvidence() { + return this.glFeatures.releaseEvidenceCollection; }, shouldShowFooter() { return this.glFeatures.releaseIssueSummary; }, + shouldRenderReleaseMetaData() { + return !this.glFeatures.releaseIssueSummary; + }, + shouldRenderMilestoneInfo() { + return Boolean(this.glFeatures.releaseIssueSummary && !_.isEmpty(this.release.milestones)); + }, }, mounted() { const hash = getLocationHash(); @@ -100,26 +114,30 @@ export default { </script> <template> <div :id="id" :class="{ 'bg-line-target-blue': isHighlighted }" class="card release-block"> + <div class="card-header d-flex align-items-center bg-white pr-0"> + <h2 class="card-title my-2 mr-auto gl-font-size-20"> + {{ release.name }} + <gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{ + __('Upcoming Release') + }}</gl-badge> + </h2> + <gl-link + v-if="shouldShowEditButton" + v-gl-tooltip + class="btn btn-default append-right-10 js-edit-button ml-2" + :title="__('Edit this release')" + :href="release._links.edit_url" + > + <icon name="pencil" /> + </gl-link> + </div> <div class="card-body"> - <div class="d-flex align-items-start"> - <h2 class="card-title mt-0 mr-auto"> - {{ release.name }} - <gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{ - __('Upcoming Release') - }}</gl-badge> - </h2> - <gl-link - v-if="shouldShowEditButton" - v-gl-tooltip - class="btn btn-default js-edit-button ml-2" - :title="__('Edit this release')" - :href="release._links.edit_url" - > - <icon name="pencil" /> - </gl-link> + <div v-if="shouldRenderMilestoneInfo"> + <release-block-milestone-info :milestones="release.milestones" /> + <hr class="mb-3 mt-0" /> </div> - <div class="card-subtitle d-flex flex-wrap text-secondary"> + <div v-if="shouldRenderReleaseMetaData" class="card-subtitle d-flex flex-wrap text-secondary"> <div class="append-right-8"> <icon name="commit" class="align-middle" /> <gl-link v-if="commitUrl" v-gl-tooltip.bottom :title="commit.title" :href="commitUrl"> @@ -217,6 +235,8 @@ export default { </div> </div> + <evidence-block v-if="hasEvidence && shouldShowEvidence" :release="release" /> + <div class="card-text prepend-top-default"> <div v-html="release.description_html"></div> </div> diff --git a/app/assets/javascripts/releases/list/components/release_block_footer.vue b/app/assets/javascripts/releases/list/components/release_block_footer.vue index 5659f0e530b..8533fc17ffd 100644 --- a/app/assets/javascripts/releases/list/components/release_block_footer.vue +++ b/app/assets/javascripts/releases/list/components/release_block_footer.vue @@ -1,6 +1,6 @@ <script> -import Icon from '~/vue_shared/components/icon.vue'; import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import { __, sprintf } from '~/locale'; @@ -50,7 +50,7 @@ export default { }, computed: { releasedAtTimeAgo() { - return this.timeFormated(this.releasedAt); + return this.timeFormatted(this.releasedAt); }, userImageAltDescription() { return this.author && this.author.username diff --git a/app/assets/javascripts/releases/list/components/release_block_milestone_info.vue b/app/assets/javascripts/releases/list/components/release_block_milestone_info.vue new file mode 100644 index 00000000000..d3e354d6157 --- /dev/null +++ b/app/assets/javascripts/releases/list/components/release_block_milestone_info.vue @@ -0,0 +1,136 @@ +<script> +import { GlProgressBar, GlLink, GlBadge, GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { __, n__, sprintf } from '~/locale'; +import { MAX_MILESTONES_TO_DISPLAY } from '../constants'; + +/** Sums the values of an array. For use with Array.reduce. */ +const sumReducer = (acc, curr) => acc + curr; + +export default { + name: 'ReleaseBlockMilestoneInfo', + components: { + GlProgressBar, + GlLink, + GlBadge, + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + milestones: { + type: Array, + required: true, + }, + }, + data() { + return { + showAllMilestones: false, + }; + }, + computed: { + percentCompleteText() { + return sprintf(__('%{percent}%{percentSymbol} complete'), { + percent: this.percentComplete, + percentSymbol: '%', + }); + }, + percentComplete() { + const percent = Math.round((this.closedIssuesCount / this.totalIssuesCount) * 100); + return Number.isNaN(percent) ? 0 : percent; + }, + allIssueStats() { + return this.milestones.map(m => m.issue_stats || {}); + }, + openIssuesCount() { + return this.allIssueStats.map(stats => stats.opened || 0).reduce(sumReducer); + }, + closedIssuesCount() { + return this.allIssueStats.map(stats => stats.closed || 0).reduce(sumReducer); + }, + totalIssuesCount() { + return this.openIssuesCount + this.closedIssuesCount; + }, + milestoneLabelText() { + return n__('Milestone', 'Milestones', this.milestones.length); + }, + issueCountsText() { + return sprintf(__('Open: %{open} • Closed: %{closed}'), { + open: this.openIssuesCount, + closed: this.closedIssuesCount, + }); + }, + milestonesToDisplay() { + return this.showAllMilestones + ? this.milestones + : this.milestones.slice(0, MAX_MILESTONES_TO_DISPLAY); + }, + showMoreLink() { + return this.milestones.length > MAX_MILESTONES_TO_DISPLAY; + }, + moreText() { + return this.showAllMilestones + ? __('show fewer') + : sprintf(__('show %{count} more'), { + count: this.milestones.length - MAX_MILESTONES_TO_DISPLAY, + }); + }, + }, + methods: { + toggleShowAll() { + this.showAllMilestones = !this.showAllMilestones; + }, + shouldRenderBullet(milestoneIndex) { + return Boolean(milestoneIndex !== this.milestonesToDisplay.length - 1 || this.showMoreLink); + }, + shouldRenderShowMoreLink(milestoneIndex) { + return Boolean(milestoneIndex === this.milestonesToDisplay.length - 1 && this.showMoreLink); + }, + }, +}; +</script> +<template> + <div class="release-block-milestone-info d-flex align-items-start flex-wrap"> + <div + v-gl-tooltip + class="milestone-progress-bar-container js-milestone-progress-bar-container d-flex flex-column align-items-start flex-shrink-1 mr-4 mb-3" + :title="__('Closed issues')" + > + <span class="mb-2">{{ percentCompleteText }}</span> + <span class="w-100"> + <gl-progress-bar :value="closedIssuesCount" :max="totalIssuesCount" variant="success" /> + </span> + </div> + <div class="d-flex flex-column align-items-start mr-4 mb-3 js-milestone-list-container"> + <span class="mb-1">{{ milestoneLabelText }}</span> + <div class="d-flex flex-wrap align-items-end"> + <template v-for="(milestone, index) in milestonesToDisplay"> + <gl-link + :key="milestone.id" + v-gl-tooltip + :title="milestone.description" + :href="milestone.web_url" + class="append-right-4" + > + {{ milestone.title }} + </gl-link> + <template v-if="shouldRenderBullet(index)"> + <span :key="'bullet-' + milestone.id" class="append-right-4">•</span> + </template> + <template v-if="shouldRenderShowMoreLink(index)"> + <gl-button :key="'more-button-' + milestone.id" variant="link" @click="toggleShowAll"> + {{ moreText }} + </gl-button> + </template> + </template> + </div> + </div> + <div class="d-flex flex-column align-items-start flex-shrink-0 mr-4 mb-3 js-issues-container"> + <span class="mb-1"> + {{ __('Issues') }} + <gl-badge pill variant="light" class="font-weight-bold">{{ totalIssuesCount }}</gl-badge> + </span> + {{ issueCountsText }} + </div> + </div> +</template> diff --git a/app/assets/javascripts/releases/list/constants.js b/app/assets/javascripts/releases/list/constants.js new file mode 100644 index 00000000000..defcd917465 --- /dev/null +++ b/app/assets/javascripts/releases/list/constants.js @@ -0,0 +1,7 @@ +/* eslint-disable import/prefer-default-export */ +// This eslint-disable ^^^ can be removed when at least +// one more constant is added to this file. Currently +// constants.js files with only a single constant +// are flagged by this rule. + +export const MAX_MILESTONES_TO_DISPLAY = 5; diff --git a/app/assets/javascripts/releases/list/store/actions.js b/app/assets/javascripts/releases/list/store/actions.js index e0a922d5ef6..b15fb69226f 100644 --- a/app/assets/javascripts/releases/list/store/actions.js +++ b/app/assets/javascripts/releases/list/store/actions.js @@ -2,6 +2,7 @@ import * as types from './mutation_types'; import createFlash from '~/flash'; import { __ } from '~/locale'; import api from '~/api'; +import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; /** * Commits a mutation to update the state while the main endpoint is being requested. @@ -16,17 +17,19 @@ export const requestReleases = ({ commit }) => commit(types.REQUEST_RELEASES); * * @param {String} projectId */ -export const fetchReleases = ({ dispatch }, projectId) => { +export const fetchReleases = ({ dispatch }, { page = '1', projectId }) => { dispatch('requestReleases'); api - .releases(projectId) - .then(({ data }) => dispatch('receiveReleasesSuccess', data)) + .releases(projectId, { page }) + .then(response => dispatch('receiveReleasesSuccess', response)) .catch(() => dispatch('receiveReleasesError')); }; -export const receiveReleasesSuccess = ({ commit }, data) => - commit(types.RECEIVE_RELEASES_SUCCESS, data); +export const receiveReleasesSuccess = ({ commit }, { data, headers }) => { + const pageInfo = parseIntPagination(normalizeHeaders(headers)); + commit(types.RECEIVE_RELEASES_SUCCESS, { data, pageInfo }); +}; export const receiveReleasesError = ({ commit }) => { commit(types.RECEIVE_RELEASES_ERROR); diff --git a/app/assets/javascripts/releases/list/store/mutations.js b/app/assets/javascripts/releases/list/store/mutations.js index b97dc6cb0ab..99fc096264a 100644 --- a/app/assets/javascripts/releases/list/store/mutations.js +++ b/app/assets/javascripts/releases/list/store/mutations.js @@ -13,13 +13,15 @@ export default { * Sets isLoading to false. * Sets hasError to false. * Sets the received data + * Sets the received pagination information * @param {Object} state - * @param {Object} data + * @param {Object} resp */ - [types.RECEIVE_RELEASES_SUCCESS](state, data) { + [types.RECEIVE_RELEASES_SUCCESS](state, { data, pageInfo }) { state.hasError = false; state.isLoading = false; state.releases = data; + state.pageInfo = pageInfo; }, /** diff --git a/app/assets/javascripts/releases/list/store/state.js b/app/assets/javascripts/releases/list/store/state.js index bf25e651c99..c251f56c9c5 100644 --- a/app/assets/javascripts/releases/list/store/state.js +++ b/app/assets/javascripts/releases/list/store/state.js @@ -2,4 +2,5 @@ export default () => ({ isLoading: false, hasError: false, releases: [], + pageInfo: {}, }); diff --git a/app/assets/javascripts/reports/components/report_item.vue b/app/assets/javascripts/reports/components/report_item.vue index 3c8a9e6ebef..51062cd7928 100644 --- a/app/assets/javascripts/reports/components/report_item.vue +++ b/app/assets/javascripts/reports/components/report_item.vue @@ -1,6 +1,6 @@ <script> -import IssueStatusIcon from '~/reports/components/issue_status_icon.vue'; import { components, componentNames } from 'ee_else_ce/reports/components/issue_body'; +import IssueStatusIcon from '~/reports/components/issue_status_icon.vue'; export default { name: 'ReportItem', diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/reports/components/summary_row.vue index aba798e63d0..1191e43d0d9 100644 --- a/app/assets/javascripts/reports/components/summary_row.vue +++ b/app/assets/javascripts/reports/components/summary_row.vue @@ -1,7 +1,7 @@ <script> +import { GlLoadingIcon } from '@gitlab/ui'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import Popover from '~/vue_shared/components/help_popover.vue'; -import { GlLoadingIcon } from '@gitlab/ui'; /** * Renders the summary row for each report diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index afb58a60155..f6b9ea5d30d 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -124,7 +124,7 @@ export default { }, { attrs: { - href: this.newBlobPath, + href: `${this.newBlobPath}${this.currentPath}`, class: 'qa-new-file-option', }, text: __('New file'), diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue index 7f974838359..6b3822151ff 100644 --- a/app/assets/javascripts/repository/components/preview/index.vue +++ b/app/assets/javascripts/repository/components/preview/index.vue @@ -35,11 +35,13 @@ export default { <template> <article class="file-holder limited-width-container readme-holder"> - <div class="file-title"> - <i aria-hidden="true" class="fa fa-file-text-o fa-fw"></i> - <gl-link :href="blob.webUrl"> - <strong>{{ blob.name }}</strong> - </gl-link> + <div class="js-file-title file-title-flex-parent"> + <div class="file-header-content"> + <i aria-hidden="true" class="fa fa-file-text-o fa-fw"></i> + <gl-link :href="blob.webUrl"> + <strong>{{ blob.name }}</strong> + </gl-link> + </div> </div> <div class="blob-viewer"> <gl-loading-icon v-if="loading > 0" size="md" class="my-4 mx-auto" /> diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index d826f209815..ae6409a0ac9 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -7,6 +7,7 @@ import TreeActionLink from './components/tree_action_link.vue'; import DirectoryDownloadLinks from './components/directory_download_links.vue'; import apolloProvider from './graphql'; import { setTitle } from './utils/title'; +import { updateFormAction } from './utils/dom'; import { parseBoolean } from '../lib/utils/common_utils'; import { webIDEUrl } from '../lib/utils/url_utility'; import { __ } from '../locale'; @@ -42,8 +43,15 @@ export default function setupVueRepositoryList() { forkNewBlobPath, forkNewDirectoryPath, forkUploadBlobPath, + uploadPath, + newDirPath, } = breadcrumbEl.dataset; + router.afterEach(({ params: { pathMatch = '/' } }) => { + updateFormAction('.js-upload-blob-form', uploadPath, pathMatch); + updateFormAction('.js-create-dir-form', newDirPath, pathMatch); + }); + // eslint-disable-next-line no-new new Vue({ el: breadcrumbEl, diff --git a/app/assets/javascripts/repository/log_tree.js b/app/assets/javascripts/repository/log_tree.js index 5bf30e625a0..6498725adb6 100644 --- a/app/assets/javascripts/repository/log_tree.js +++ b/app/assets/javascripts/repository/log_tree.js @@ -1,5 +1,5 @@ -import axios from '~/lib/utils/axios_utils'; import { normalizeData } from 'ee_else_ce/repository/utils/commit'; +import axios from '~/lib/utils/axios_utils'; import getCommits from './queries/getCommits.query.graphql'; import getProjectPath from './queries/getProjectPath.query.graphql'; import getRef from './queries/getRef.query.graphql'; @@ -7,8 +7,8 @@ import getRef from './queries/getRef.query.graphql'; let fetchpromise; let resolvers = []; -export function resolveCommit(commits, { resolve, entry }) { - const commit = commits.find(c => c.fileName === entry.name && c.type === entry.type); +export function resolveCommit(commits, path, { resolve, entry }) { + const commit = commits.find(c => c.filePath === `${path}/${entry.name}` && c.type === entry.type); if (commit) { resolve(commit); @@ -35,13 +35,13 @@ export function fetchLogsTree(client, path, offset, resolver = null) { .then(({ data, headers }) => { const headerLogsOffset = headers['more-logs-offset']; const { commits } = client.readQuery({ query: getCommits }); - const newCommitData = [...commits, ...normalizeData(data)]; + const newCommitData = [...commits, ...normalizeData(data, path)]; client.writeQuery({ query: getCommits, data: { commits: newCommitData }, }); - resolvers.forEach(r => resolveCommit(newCommitData, r)); + resolvers.forEach(r => resolveCommit(newCommitData, path, r)); fetchpromise = null; diff --git a/app/assets/javascripts/repository/utils/commit.js b/app/assets/javascripts/repository/utils/commit.js index 6c204b57b37..3973798605d 100644 --- a/app/assets/javascripts/repository/utils/commit.js +++ b/app/assets/javascripts/repository/utils/commit.js @@ -1,11 +1,12 @@ // eslint-disable-next-line import/prefer-default-export -export function normalizeData(data, extra = () => {}) { +export function normalizeData(data, path, extra = () => {}) { return data.map(d => ({ sha: d.commit.id, message: d.commit.message, committedDate: d.commit.committed_date, commitPath: d.commit_path, fileName: d.file_name, + filePath: `${path}/${d.file_name}`, type: d.type, __typename: 'LogTreeCommit', ...extra(d), diff --git a/app/assets/javascripts/repository/utils/dom.js b/app/assets/javascripts/repository/utils/dom.js index 963e6fc0bc4..81565a00d82 100644 --- a/app/assets/javascripts/repository/utils/dom.js +++ b/app/assets/javascripts/repository/utils/dom.js @@ -1,4 +1,11 @@ -// eslint-disable-next-line import/prefer-default-export export const updateElementsVisibility = (selector, isVisible) => { document.querySelectorAll(selector).forEach(elem => elem.classList.toggle('hidden', !isVisible)); }; + +export const updateFormAction = (selector, basePath, path) => { + const form = document.querySelector(selector); + + if (form) { + form.action = `${basePath}${path}`; + } +}; diff --git a/app/assets/javascripts/serverless/components/area.vue b/app/assets/javascripts/serverless/components/area.vue index a1a8cd3acbd..272c0bd5614 100644 --- a/app/assets/javascripts/serverless/components/area.vue +++ b/app/assets/javascripts/serverless/components/area.vue @@ -1,7 +1,7 @@ <script> import { GlAreaChart } from '@gitlab/ui/dist/charts'; -import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; import dateFormat from 'dateformat'; +import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; import { X_INTERVAL } from '../constants'; import { validateGraphData } from '../utils'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index 9e66869515c..308bc4a2ddd 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -1,7 +1,7 @@ <script> -import { sprintf, s__ } from '~/locale'; import { mapState, mapActions, mapGetters } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; +import { sprintf, s__ } from '~/locale'; import FunctionRow from './function_row.vue'; import EnvironmentRow from './environment_row.vue'; import EmptyState from './empty_state.vue'; @@ -44,12 +44,14 @@ export default { 'Serverless|Your repository does not have a corresponding %{startTag}serverless.yml%{endTag} file.', ), { startTag: '<code>', endTag: '</code>' }, + false, ); }, noGitlabYamlConfigured() { return sprintf( s__('Serverless|Your %{startTag}.gitlab-ci.yml%{endTag} file is not properly configured.'), { startTag: '<code>', endTag: '</code>' }, + false, ); }, mismatchedServerlessFunctions() { @@ -58,6 +60,7 @@ export default { "Serverless|The functions listed in the %{startTag}serverless.yml%{endTag} file don't match the namespace of your cluster.", ), { startTag: '<code>', endTag: '</code>' }, + false, ); }, }, @@ -111,15 +114,9 @@ export default { }} </p> <ul> - <li> - {{ noServerlessConfigFile }} - </li> - <li> - {{ noGitlabYamlConfigured }} - </li> - <li> - {{ mismatchedServerlessFunctions }} - </li> + <li v-html="noServerlessConfigFile"></li> + <li v-html="noGitlabYamlConfigured"></li> + <li v-html="mismatchedServerlessFunctions"></li> <li>{{ s__('Serverless|The deploy job has not finished.') }}</li> </ul> diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue index df950e79690..2757d64bd7d 100644 --- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue +++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue @@ -1,11 +1,11 @@ <script> import $ from 'jquery'; +import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; +import { GlModal, GlTooltipDirective } from '@gitlab/ui'; import createFlash from '~/flash'; import Icon from '~/vue_shared/components/icon.vue'; -import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; import { __, s__ } from '~/locale'; import Api from '~/api'; -import { GlModal, GlTooltipDirective } from '@gitlab/ui'; import eventHub from './event_hub'; import EmojiMenuInModal from './emoji_menu_in_modal'; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue index 6633a63d046..9a60172db2e 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue @@ -1,7 +1,6 @@ <script> -import { __, sprintf } from '~/locale'; import { GlTooltipDirective, GlLink } from '@gitlab/ui'; -import { joinPaths } from '~/lib/utils/url_utility'; +import { __, sprintf } from '~/locale'; import AssigneeAvatar from './assignee_avatar.vue'; export default { @@ -60,7 +59,7 @@ export default { }; }, assigneeUrl() { - return joinPaths(`${this.rootPath}`, `${this.user.username}`); + return this.user.web_url; }, }, }; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue index b107e9789a7..f4dac38b9e1 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue @@ -32,14 +32,13 @@ export default { }; </script> <template> - <div class="title hide-collapsed" data-qa-selector="assignee_title"> + <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="#" - data-qa-selector="assignee_edit_link" data-track-event="click_edit_button" data-track-label="right_sidebar" data-track-property="assignee" diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue index 5b4a43399ca..7375855f899 100644 --- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue @@ -1,6 +1,6 @@ <script> -import { __, sprintf } from '~/locale'; import { GlTooltipDirective } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; import CollapsedAssignee from './collapsed_assignee.vue'; const DEFAULT_MAX_COUNTER = 99; diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue index 902aeb9b8e4..f88bde624b4 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue @@ -1,6 +1,6 @@ <script> -import { __ } from '~/locale'; import $ from 'jquery'; +import { __ } from '~/locale'; import eventHub from '../../event_hub'; export default { diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index 4bfc8fa7eec..38b19d66163 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -1,8 +1,8 @@ <script> +import { GlLoadingIcon } from '@gitlab/ui'; import { __, n__, sprintf } from '~/locale'; import tooltip from '~/vue_shared/directives/tooltip'; import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; -import { GlLoadingIcon } from '@gitlab/ui'; export default { directives: { diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue index 0e489b28593..3b92ead8859 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue @@ -48,6 +48,12 @@ export default { }, }, computed: { + tracking() { + return { + // eslint-disable-next-line no-underscore-dangle + category: this.$options._componentTag, + }; + }, showLoadingState() { return this.subscribed === null; }, diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue index 06aca547183..4cb8d9ebd62 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue @@ -1,7 +1,7 @@ <script> +import { GlProgressBar } from '@gitlab/ui'; import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility'; import tooltip from '../../../vue_shared/directives/tooltip'; -import { GlProgressBar } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; export default { diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue index 3d96405896d..3b7df369237 100644 --- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue @@ -1,7 +1,7 @@ <script> +import { GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import tooltip from '~/vue_shared/directives/tooltip'; -import { GlLoadingIcon } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; @@ -59,6 +59,9 @@ export default { collapsedButtonIcon() { return this.isTodo ? 'todo-done' : 'todo-add'; }, + collapsedButtonIconVisible() { + return this.collapsed && !this.isActionActive; + }, }, methods: { handleButtonClick() { @@ -82,8 +85,12 @@ export default { data-boundary="viewport" @click="handleButtonClick" > - <icon v-show="collapsed" :class="collapsedButtonIconClasses" :name="collapsedButtonIcon" /> - <span v-show="!collapsed" class="issuable-todo-inner"> {{ buttonLabel }} </span> + <icon + v-show="collapsedButtonIconVisible" + :class="collapsedButtonIconClasses" + :name="collapsedButtonIcon" + /> + <span v-show="!collapsed" class="issuable-todo-inner">{{ buttonLabel }}</span> <gl-loading-icon v-show="isActionActive" :inline="true" /> </button> </template> diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index 4a7000cbbda..ce869a625bf 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -1,7 +1,7 @@ +import Store from 'ee_else_ce/sidebar/stores/sidebar_store'; import { visitUrl } from '../lib/utils/url_utility'; import Flash from '../flash'; import Service from './services/sidebar_service'; -import Store from 'ee_else_ce/sidebar/stores/sidebar_store'; import { __ } from '~/locale'; export default class SidebarMediator { diff --git a/app/assets/javascripts/snippet/snippet_embed.js b/app/assets/javascripts/snippet/snippet_embed.js index 6606271c4fa..65dd62f6af9 100644 --- a/app/assets/javascripts/snippet/snippet_embed.js +++ b/app/assets/javascripts/snippet/snippet_embed.js @@ -1,28 +1,35 @@ import { __ } from '~/locale'; +import { parseUrlPathname, parseUrl } from '../lib/utils/common_utils'; + +function swapActiveState(activateBtn, deactivateBtn) { + activateBtn.classList.add('is-active'); + deactivateBtn.classList.remove('is-active'); +} export default () => { const shareBtn = document.querySelector('.js-share-btn'); if (shareBtn) { - const { protocol, host, pathname } = window.location; - const embedBtn = document.querySelector('.js-embed-btn'); - const snippetUrlArea = document.querySelector('.js-snippet-url-area'); const embedAction = document.querySelector('.js-embed-action'); - const url = `${protocol}//${host + pathname}`; + const dataUrl = snippetUrlArea.getAttribute('data-url'); + + snippetUrlArea.addEventListener('click', () => snippetUrlArea.select()); shareBtn.addEventListener('click', () => { - shareBtn.classList.add('is-active'); - embedBtn.classList.remove('is-active'); - snippetUrlArea.value = url; + swapActiveState(shareBtn, embedBtn); + snippetUrlArea.value = dataUrl; embedAction.innerText = __('Share'); }); embedBtn.addEventListener('click', () => { - embedBtn.classList.add('is-active'); - shareBtn.classList.remove('is-active'); - const scriptTag = `<script src="${url}.js"></script>`; + const parser = parseUrl(dataUrl); + const url = `${parser.origin + parseUrlPathname(dataUrl)}`; + const params = parser.search; + const scriptTag = `<script src="${url}.js${params}"></script>`; + + swapActiveState(embedBtn, shareBtn); snippetUrlArea.value = scriptTag; embedAction.innerText = __('Embed'); }); diff --git a/app/assets/javascripts/snippets/components/app.vue b/app/assets/javascripts/snippets/components/app.vue new file mode 100644 index 00000000000..bd2cb8e4595 --- /dev/null +++ b/app/assets/javascripts/snippets/components/app.vue @@ -0,0 +1,50 @@ +<script> +import GetSnippetQuery from '../queries/snippet.query.graphql'; +import SnippetHeader from './snippet_header.vue'; +import { GlLoadingIcon } from '@gitlab/ui'; + +export default { + components: { + SnippetHeader, + GlLoadingIcon, + }, + apollo: { + snippet: { + query: GetSnippetQuery, + variables() { + return { + ids: this.snippetGid, + }; + }, + update: data => data.snippets.edges[0].node, + }, + }, + props: { + snippetGid: { + type: String, + required: true, + }, + }, + data() { + return { + snippet: {}, + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.snippet.loading; + }, + }, +}; +</script> +<template> + <div class="js-snippet-view"> + <gl-loading-icon + v-if="isLoading" + :label="__('Loading snippet')" + :size="2" + class="loading-animation prepend-top-20 append-bottom-20" + /> + <snippet-header v-else :snippet="snippet" /> + </div> +</template> diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue new file mode 100644 index 00000000000..e8f1bfeaf43 --- /dev/null +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -0,0 +1,241 @@ +<script> +import { __ } from '~/locale'; +import { + GlAvatar, + GlIcon, + GlSprintf, + GlButton, + GlModal, + GlAlert, + GlLoadingIcon, + GlDropdown, + GlDropdownItem, +} from '@gitlab/ui'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql'; +import CanCreatePersonalSnippet from '../queries/userPermissions.query.graphql'; +import CanCreateProjectSnippet from '../queries/projectPermissions.query.graphql'; + +export default { + components: { + GlAvatar, + GlIcon, + GlSprintf, + GlButton, + GlModal, + GlAlert, + GlLoadingIcon, + GlDropdown, + GlDropdownItem, + TimeAgoTooltip, + }, + apollo: { + canCreateSnippet: { + query() { + return this.snippet.project ? CanCreateProjectSnippet : CanCreatePersonalSnippet; + }, + variables() { + return { + fullPath: this.snippet.project ? this.snippet.project.fullPath : undefined, + }; + }, + update(data) { + return this.snippet.project + ? data.project.userPermissions.createSnippet + : data.currentUser.userPermissions.createSnippet; + }, + }, + }, + props: { + snippet: { + type: Object, + required: true, + }, + }, + data() { + return { + isDeleting: false, + errorMessage: '', + canCreateSnippet: false, + }; + }, + computed: { + personalSnippetActions() { + return [ + { + condition: this.snippet.userPermissions.updateSnippet, + text: __('Edit'), + href: this.editLink, + click: undefined, + variant: 'outline-info', + cssClass: undefined, + }, + { + condition: this.snippet.userPermissions.adminSnippet, + text: __('Delete'), + href: undefined, + click: this.showDeleteModal, + variant: 'outline-danger', + cssClass: 'btn-inverted btn-danger ml-2', + }, + { + condition: this.canCreateSnippet, + text: __('New snippet'), + href: this.snippet.project + ? `${this.snippet.project.webUrl}/snippets/new` + : '/snippets/new', + click: undefined, + variant: 'outline-success', + cssClass: 'btn-inverted btn-success ml-2', + }, + ]; + }, + editLink() { + return `${this.snippet.webUrl}/edit`; + }, + visibility() { + return this.snippet.visibilityLevel; + }, + snippetVisibilityLevelDescription() { + switch (this.visibility) { + case 'private': + return this.snippet.project !== null + ? __('The snippet is visible only to project members.') + : __('The snippet is visible only to me.'); + case 'internal': + return __('The snippet is visible to any logged in user.'); + default: + return __('The snippet can be accessed without any authentication.'); + } + }, + visibilityLevelIcon() { + switch (this.visibility) { + case 'private': + return 'lock'; + case 'internal': + return 'shield'; + default: + return 'earth'; + } + }, + }, + methods: { + redirectToSnippets() { + window.location.pathname = 'dashboard/snippets'; + }, + closeDeleteModal() { + this.$refs.deleteModal.hide(); + }, + showDeleteModal() { + this.$refs.deleteModal.show(); + }, + deleteSnippet() { + this.isDeleting = true; + this.$apollo + .mutate({ + mutation: DeleteSnippetMutation, + variables: { id: this.snippet.id }, + }) + .then(() => { + this.isDeleting = false; + this.errorMessage = undefined; + this.closeDeleteModal(); + this.redirectToSnippets(); + }) + .catch(err => { + this.isDeleting = false; + this.errorMessage = err.message; + }); + }, + }, +}; +</script> +<template> + <div class="detail-page-header"> + <div class="detail-page-header-body"> + <div + class="snippet-box qa-snippet-box has-tooltip d-flex align-items-center append-right-5 mb-1" + :title="snippetVisibilityLevelDescription" + data-container="body" + > + <span class="sr-only"> + {{ s__(`VisibilityLevel|${visibility}`) }} + </span> + <gl-icon :name="visibilityLevelIcon" :size="14" /> + </div> + <div class="creator"> + <gl-sprintf message="Authored %{timeago} by %{author}"> + <template #timeago> + <time-ago-tooltip + :time="snippet.createdAt" + tooltip-placement="bottom" + css-class="snippet_updated_ago" + /> + </template> + <template #author> + <a :href="snippet.author.webUrl" class="d-inline"> + <gl-avatar :size="24" :src="snippet.author.avatarUrl" /> + <span class="bold">{{ snippet.author.name }}</span> + </a> + </template> + </gl-sprintf> + </div> + </div> + + <div class="detail-page-header-actions"> + <div class="d-none d-sm-block"> + <template v-for="(action, index) in personalSnippetActions"> + <gl-button + v-if="action.condition" + :key="index" + :variant="action.variant" + :class="action.cssClass" + :href="action.href || undefined" + @click="action.click ? action.click() : undefined" + > + {{ action.text }} + </gl-button> + </template> + </div> + <div class="d-block d-sm-none dropdown"> + <gl-dropdown :text="__('Options')" class="w-100" toggle-class="text-center"> + <gl-dropdown-item + v-for="(action, index) in personalSnippetActions" + :key="index" + :href="action.href || undefined" + @click="action.click ? action.click() : undefined" + >{{ action.text }}</gl-dropdown-item + > + </gl-dropdown> + </div> + </div> + + <gl-modal ref="deleteModal" modal-id="delete-modal" title="Example title"> + <template #modal-title>{{ __('Delete snippet?') }}</template> + + <gl-alert v-if="errorMessage" variant="danger" class="mb-2" @dismiss="errorMessage = ''">{{ + errorMessage + }}</gl-alert> + + <gl-sprintf message="Are you sure you want to delete %{name}?"> + <template #name + ><strong>{{ snippet.title }}</strong></template + > + </gl-sprintf> + + <template #modal-footer> + <gl-button @click="closeDeleteModal">{{ __('Cancel') }}</gl-button> + <gl-button + variant="danger" + :disabled="isDeleting" + data-qa-selector="delete_snippet_button" + @click="deleteSnippet" + > + <gl-loading-icon v-if="isDeleting" inline /> + {{ __('Delete snippet') }} + </gl-button> + </template> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/snippets/fragments/author.fragment.graphql b/app/assets/javascripts/snippets/fragments/author.fragment.graphql new file mode 100644 index 00000000000..2684bd0fa37 --- /dev/null +++ b/app/assets/javascripts/snippets/fragments/author.fragment.graphql @@ -0,0 +1,8 @@ +fragment Author on Snippet { + author { + name, + avatarUrl, + username, + webUrl + } +}
\ No newline at end of file diff --git a/app/assets/javascripts/snippets/fragments/project.fragment.graphql b/app/assets/javascripts/snippets/fragments/project.fragment.graphql new file mode 100644 index 00000000000..7d65789c67b --- /dev/null +++ b/app/assets/javascripts/snippets/fragments/project.fragment.graphql @@ -0,0 +1,6 @@ +fragment Project on Snippet { + project { + fullPath + webUrl + } +}
\ No newline at end of file diff --git a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql new file mode 100644 index 00000000000..57348a422ec --- /dev/null +++ b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql @@ -0,0 +1,13 @@ +fragment SnippetBase on Snippet { + id + title + description + createdAt + updatedAt + visibilityLevel + webUrl + userPermissions { + adminSnippet + updateSnippet + } +}
\ No newline at end of file diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js new file mode 100644 index 00000000000..654856f8d14 --- /dev/null +++ b/app/assets/javascripts/snippets/index.js @@ -0,0 +1,34 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; + +import SnippetsApp from './components/app.vue'; + +Vue.use(VueApollo); +Vue.use(Translate); + +export default () => { + const el = document.getElementById('js-snippet-view'); + + if (!el) { + return false; + } + + const { snippetGid } = el.dataset; + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el, + apolloProvider, + render(createElement) { + return createElement(SnippetsApp, { + props: { + snippetGid, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql new file mode 100644 index 00000000000..0c829cbdee6 --- /dev/null +++ b/app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql @@ -0,0 +1,5 @@ +mutation DeleteSnippet($id: ID!) { + destroySnippet(input: {id: $id}) { + errors + } +}
\ No newline at end of file diff --git a/app/assets/javascripts/snippets/queries/projectPermissions.query.graphql b/app/assets/javascripts/snippets/queries/projectPermissions.query.graphql new file mode 100644 index 00000000000..288bd0889bf --- /dev/null +++ b/app/assets/javascripts/snippets/queries/projectPermissions.query.graphql @@ -0,0 +1,7 @@ +query CanCreateProjectSnippet($fullPath: ID!) { + project(fullPath: $fullPath) { + userPermissions { + createSnippet + } + } +}
\ No newline at end of file diff --git a/app/assets/javascripts/snippets/queries/snippet.query.graphql b/app/assets/javascripts/snippets/queries/snippet.query.graphql new file mode 100644 index 00000000000..1cb2c86c4d8 --- /dev/null +++ b/app/assets/javascripts/snippets/queries/snippet.query.graphql @@ -0,0 +1,15 @@ +#import '../fragments/snippetBase.fragment.graphql' +#import '../fragments/project.fragment.graphql' +#import '../fragments/author.fragment.graphql' + +query GetSnippetQuery($ids: [ID!]) { + snippets(ids: $ids) { + edges { + node { + ...SnippetBase + ...Project + ...Author + } + } + } +} diff --git a/app/assets/javascripts/snippets/queries/userPermissions.query.graphql b/app/assets/javascripts/snippets/queries/userPermissions.query.graphql new file mode 100644 index 00000000000..f5b97b3d0f0 --- /dev/null +++ b/app/assets/javascripts/snippets/queries/userPermissions.query.graphql @@ -0,0 +1,7 @@ +query CanCreatePersonalSnippet { + currentUser { + userPermissions { + createSnippet + } + } +}
\ No newline at end of file diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js index 7c0097fbe37..a17b8a047c0 100644 --- a/app/assets/javascripts/tracking.js +++ b/app/assets/javascripts/tracking.js @@ -73,20 +73,25 @@ export default class Tracking { return handlers; } - static mixin(opts) { + static mixin(opts = {}) { return { - data() { - return { - tracking: { - // eslint-disable-next-line no-underscore-dangle - category: this.$options.name || this.$options._componentTag, - }, - }; + computed: { + trackingCategory() { + const localCategory = this.tracking ? this.tracking.category : null; + return localCategory || opts.category; + }, + trackingOptions() { + return { ...opts, ...this.tracking }; + }, }, methods: { - track(action, data) { - const category = opts.category || data.category || this.tracking.category; - Tracking.event(category || 'unspecified', action, { ...opts, ...this.tracking, ...data }); + track(action, data = {}) { + const category = data.category || this.trackingCategory; + const options = { + ...this.trackingOptions, + ...data, + }; + Tracking.event(category, action, options); }, }, }; diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index 7d6a725b30f..157d89a3a40 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -17,6 +17,7 @@ const handleUserPopoverMouseOut = event => { renderedPopover.$destroy(); renderedPopover = null; } + target.removeAttribute('aria-describedby'); }; /** diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index da1a7c290f8..57fbb88ca2e 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, one-var, no-var, prefer-rest-params, vars-on-top, consistent-return, no-shadow, no-else-return, no-self-compare, no-unused-expressions, yoda, prefer-spread, camelcase, no-param-reassign */ +/* eslint-disable func-names, prefer-rest-params, consistent-return, no-shadow, no-else-return, no-self-compare, no-unused-expressions, yoda, prefer-spread, camelcase, no-param-reassign */ /* global Issuable */ /* global emitSidebarEvent */ @@ -13,7 +13,7 @@ import { parseBoolean } from './lib/utils/common_utils'; window.emitSidebarEvent = window.emitSidebarEvent || $.noop; function UsersSelect(currentUser, els, options = {}) { - var $els; + const $els = $(els || '.js-user-search'); this.users = this.users.bind(this); this.user = this.user.bind(this); this.usersPath = '/autocomplete/users.json'; @@ -28,36 +28,11 @@ function UsersSelect(currentUser, els, options = {}) { const { handleClick } = options; - $els = $(els); - - if (!els) { - $els = $('.js-user-search'); - } - $els.each( (function(_this) { return function(i, dropdown) { - var options = {}; - var $block, - $collapsedSidebar, - $dropdown, - $loading, - $selectbox, - $value, - abilityName, - assignTo, - assigneeTemplate, - collapsedAssigneeTemplate, - defaultLabel, - defaultNullUser, - firstUser, - issueURL, - selectedId, - selectedIdDefault, - showAnyUser, - showNullUser, - showMenuAbove; - $dropdown = $(dropdown); + const options = {}; + const $dropdown = $(dropdown); options.projectId = $dropdown.data('projectId'); options.groupId = $dropdown.data('groupId'); options.showCurrentUser = $dropdown.data('currentUser'); @@ -65,22 +40,25 @@ function UsersSelect(currentUser, els, options = {}) { 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'); - showAnyUser = $dropdown.data('anyUser'); - firstUser = $dropdown.data('firstUser'); + const showNullUser = $dropdown.data('nullUser'); + const defaultNullUser = $dropdown.data('nullUserDefault'); + const showMenuAbove = $dropdown.data('showMenuAbove'); + const showAnyUser = $dropdown.data('anyUser'); + const firstUser = $dropdown.data('firstUser'); options.authorId = $dropdown.data('authorId'); - defaultLabel = $dropdown.data('defaultLabel'); - issueURL = $dropdown.data('issueUpdate'); - $selectbox = $dropdown.closest('.selectbox'); - $block = $selectbox.closest('.block'); - abilityName = $dropdown.data('abilityName'); - $value = $block.find('.value'); - $collapsedSidebar = $block.find('.sidebar-collapsed-user'); - $loading = $block.find('.block-loading').fadeOut(); - selectedIdDefault = defaultNullUser && showNullUser ? 0 : null; - selectedId = $dropdown.data('selected'); + const defaultLabel = $dropdown.data('defaultLabel'); + const issueURL = $dropdown.data('issueUpdate'); + const $selectbox = $dropdown.closest('.selectbox'); + let $block = $selectbox.closest('.block'); + const abilityName = $dropdown.data('abilityName'); + let $value = $block.find('.value'); + const $collapsedSidebar = $block.find('.sidebar-collapsed-user'); + const $loading = $block.find('.block-loading').fadeOut(); + const selectedIdDefault = defaultNullUser && showNullUser ? 0 : null; + let selectedId = $dropdown.data('selected'); + let assignTo; + let assigneeTemplate; + let collapsedAssigneeTemplate; if (selectedId === undefined) { selectedId = selectedIdDefault; @@ -207,15 +185,15 @@ function UsersSelect(currentUser, els, options = {}) { }); assignTo = function(selected) { - var data; - data = {}; + const data = {}; data[abilityName] = {}; data[abilityName].assignee_id = selected != null ? selected : null; $loading.removeClass('hidden').fadeIn(); $dropdown.trigger('loading.gl.dropdown'); return axios.put(issueURL, data).then(({ data }) => { - var user, tooltipTitle; + let user = {}; + let tooltipTitle = user.name; $dropdown.trigger('loaded.gl.dropdown'); $loading.fadeOut(); if (data.assignee) { @@ -471,10 +449,9 @@ function UsersSelect(currentUser, els, options = {}) { } } - var isIssueIndex, isMRIndex, page, selected; - page = $('body').attr('data-page'); - isIssueIndex = page === 'projects:issues:index'; - isMRIndex = page === page && page === 'projects:merge_requests:index'; + const page = $('body').attr('data-page'); + const isIssueIndex = page === 'projects:issues:index'; + const isMRIndex = page === page && page === 'projects:merge_requests:index'; if ( $dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown') @@ -501,7 +478,7 @@ function UsersSelect(currentUser, els, options = {}) { } else if ($dropdown.hasClass('js-filter-submit')) { return $dropdown.closest('form').submit(); } else if (!$dropdown.hasClass('js-multiselect')) { - selected = $dropdown + const selected = $dropdown .closest('.selectbox') .find(`input[name='${$dropdown.data('fieldName')}']`) .val(); @@ -544,9 +521,8 @@ function UsersSelect(currentUser, els, options = {}) { }, updateLabel: $dropdown.data('dropdownTitle'), renderRow(user) { - var avatar, img, username; - username = user.username ? `@${user.username}` : ''; - avatar = user.avatar_url ? user.avatar_url : gon.default_avatar_url; + const username = user.username ? `@${user.username}` : ''; + const avatar = user.avatar_url ? user.avatar_url : gon.default_avatar_url; let selected = false; @@ -565,7 +541,7 @@ function UsersSelect(currentUser, els, options = {}) { selected = user.id === selectedId; } - img = ''; + let img = ''; if (user.beforeDivider != null) { `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${_.escape( user.name, @@ -586,35 +562,34 @@ function UsersSelect(currentUser, els, options = {}) { $('.ajax-users-select').each( (function(_this) { return function(i, select) { - var firstUser, showAnyUser, showEmailUser, showNullUser; - var options = {}; + const options = {}; options.skipLdap = $(select).hasClass('skip_ldap'); options.projectId = $(select).data('projectId'); options.groupId = $(select).data('groupId'); options.showCurrentUser = $(select).data('currentUser'); options.authorId = $(select).data('authorId'); options.skipUsers = $(select).data('skipUsers'); - showNullUser = $(select).data('nullUser'); - showAnyUser = $(select).data('anyUser'); - showEmailUser = $(select).data('emailUser'); - firstUser = $(select).data('firstUser'); + const showNullUser = $(select).data('nullUser'); + const showAnyUser = $(select).data('anyUser'); + const showEmailUser = $(select).data('emailUser'); + const firstUser = $(select).data('firstUser'); return $(select).select2({ placeholder: __('Search for a user'), multiple: $(select).hasClass('multiselect'), minimumInputLength: 0, query(query) { return _this.users(query.term, options, users => { - var anyUser, data, emailUser, index, len, name, nullUser, obj, ref; - data = { + let name; + const data = { results: users, }; if (query.term.length === 0) { if (firstUser) { // Move current user to the front of the list - ref = data.results; + const ref = data.results; - for (index = 0, len = ref.length; index < len; index += 1) { - obj = ref[index]; + for (let index = 0, len = ref.length; index < len; index += 1) { + const obj = ref[index]; if (obj.username === firstUser) { data.results.splice(index, 1); data.results.unshift(obj); @@ -623,7 +598,7 @@ function UsersSelect(currentUser, els, options = {}) { } } if (showNullUser) { - nullUser = { + const nullUser = { name: s__('UsersSelect|Unassigned'), id: 0, }; @@ -634,7 +609,7 @@ function UsersSelect(currentUser, els, options = {}) { if (name === true) { name = s__('UsersSelect|Any User'); } - anyUser = { + const anyUser = { name, id: null, }; @@ -646,8 +621,8 @@ function UsersSelect(currentUser, els, options = {}) { data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/) ) { - var trimmed = query.term.trim(); - emailUser = { + const trimmed = query.term.trim(); + const emailUser = { name: sprintf(__('Invite "%{trimmed}" by email'), { trimmed }), username: trimmed, id: trimmed, @@ -659,18 +634,15 @@ function UsersSelect(currentUser, els, options = {}) { }); }, initSelection() { - var args; - args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; return _this.initSelection.apply(_this, args); }, formatResult() { - var args; - args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; return _this.formatResult.apply(_this, args); }, formatSelection() { - var args; - args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; return _this.formatSelection.apply(_this, args); }, dropdownCssClass: 'ajax-users-dropdown', @@ -687,10 +659,9 @@ function UsersSelect(currentUser, els, options = {}) { } UsersSelect.prototype.initSelection = function(element, callback) { - var id, nullUser; - id = $(element).val(); + const id = $(element).val(); if (id === '0') { - nullUser = { + const nullUser = { name: s__('UsersSelect|Unassigned'), }; return callback(nullUser); @@ -700,11 +671,9 @@ UsersSelect.prototype.initSelection = function(element, callback) { }; UsersSelect.prototype.formatResult = function(user) { - var avatar; + let avatar = gon.default_avatar_url; if (user.avatar_url) { avatar = user.avatar_url; - } else { - avatar = gon.default_avatar_url; } return ` <div class='user-result'> @@ -732,8 +701,7 @@ UsersSelect.prototype.user = function(user_id, callback) { return false; } - var url; - url = this.buildUrl(this.userPath); + let url = this.buildUrl(this.userPath); url = url.replace(':id', user_id); return axios.get(url).then(({ data }) => { callback(data); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue deleted file mode 100644 index 1873e09c370..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue +++ /dev/null @@ -1,245 +0,0 @@ -<script> -import { GlTooltipDirective } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; -import FilteredSearchDropdown from '~/vue_shared/components/filtered_search_dropdown.vue'; -import { __ } from '~/locale'; -import timeagoMixin from '../../vue_shared/mixins/timeago'; -import LoadingButton from '../../vue_shared/components/loading_button.vue'; -import { visitUrl } from '../../lib/utils/url_utility'; -import createFlash from '../../flash'; -import MemoryUsage from './memory_usage.vue'; -import StatusIcon from './mr_widget_status_icon.vue'; -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, - MemoryUsage, - StatusIcon, - Icon, - TooltipOnTruncate, - FilteredSearchDropdown, - ReviewAppLink, - VisualReviewAppLink: () => - import('ee_component/vue_merge_request_widget/components/visual_review_app_link.vue'), - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - mixins: [timeagoMixin], - props: { - deployment: { - type: Object, - required: true, - }, - showMetrics: { - type: Boolean, - required: true, - }, - showVisualReviewApp: { - type: Boolean, - required: false, - default: false, - }, - visualReviewAppMeta: { - type: Object, - required: false, - default: () => ({ - sourceProjectId: '', - sourceProjectPath: '', - mergeRequestId: '', - appUrl: '', - }), - }, - }, - deployedTextMap: { - running: __('Deploying to'), - success: __('Deployed to'), - failed: __('Failed to deploy to'), - created: __('Will deploy to'), - canceled: __('Failed to deploy to'), - }, - data() { - return { - isStopping: false, - }; - }, - computed: { - deployTimeago() { - return this.timeFormated(this.deployment.deployed_at); - }, - deploymentExternalUrl() { - if (this.deployment.changes && this.deployment.changes.length === 1) { - return this.deployment.changes[0].external_url; - } - return this.deployment.external_url; - }, - hasExternalUrls() { - return Boolean(this.deployment.external_url && this.deployment.external_url_formatted); - }, - hasDeploymentTime() { - return Boolean(this.deployment.deployed_at && this.deployment.deployed_at_formatted); - }, - hasDeploymentMeta() { - return Boolean(this.deployment.url && this.deployment.name); - }, - hasMetrics() { - return Boolean(this.deployment.metrics_url); - }, - deployedText() { - return this.$options.deployedTextMap[this.deployment.status]; - }, - isDeployInProgress() { - return this.deployment.status === 'running'; - }, - deployInProgressTooltip() { - return this.isDeployInProgress - ? __('Stopping this environment is currently not possible as a deployment is in progress') - : ''; - }, - shouldRenderDropdown() { - return this.deployment.changes && this.deployment.changes.length > 1; - }, - showMemoryUsage() { - return this.hasMetrics && this.showMetrics; - }, - }, - methods: { - stopEnvironment() { - const msg = __('Are you sure you want to stop this environment?'); - const isConfirmed = confirm(msg); // eslint-disable-line - - if (isConfirmed) { - this.isStopping = true; - - MRWidgetService.stopEnvironment(this.deployment.stop_url) - .then(res => res.data) - .then(data => { - if (data.redirect_url) { - visitUrl(data.redirect_url); - } - - this.isStopping = false; - }) - .catch(() => { - createFlash( - __('Something went wrong while stopping this environment. Please try again.'), - ); - this.isStopping = false; - }); - } - }, - }, -}; -</script> - -<template> - <div class="deploy-heading"> - <div class="ci-widget media"> - <div class="media-body"> - <div class="deploy-body"> - <div class="js-deployment-info deployment-info"> - <template v-if="hasDeploymentMeta"> - <span> {{ deployedText }} </span> - <tooltip-on-truncate - :title="deployment.name" - truncate-target="child" - class="deploy-link label-truncate" - > - <a - :href="deployment.url" - target="_blank" - rel="noopener noreferrer nofollow" - class="js-deploy-meta" - > - {{ deployment.name }} - </a> - </tooltip-on-truncate> - </template> - <span - v-if="hasDeploymentTime" - v-gl-tooltip - :title="deployment.deployed_at_formatted" - class="js-deploy-time" - > - {{ deployTimeago }} - </span> - <memory-usage - v-if="showMemoryUsage" - :metrics-url="deployment.metrics_url" - :metrics-monitoring-url="deployment.metrics_monitoring_url" - /> - </div> - <div> - <template v-if="hasExternalUrls"> - <filtered-search-dropdown - v-if="shouldRenderDropdown" - class="js-mr-wigdet-deployment-dropdown inline" - :items="deployment.changes" - :main-action-link="deploymentExternalUrl" - filter-key="path" - > - <template slot="mainAction" slot-scope="slotProps"> - <review-app-link - :link="deploymentExternalUrl" - :css-class="`deploy-link js-deploy-url inline ${slotProps.className}`" - /> - </template> - - <template slot="result" slot-scope="slotProps"> - <a - :href="slotProps.result.external_url" - target="_blank" - rel="noopener noreferrer nofollow" - class="menu-item" - > - <strong class="str-truncated-100 append-bottom-0 d-block"> - {{ slotProps.result.path }} - </strong> - - <p class="text-secondary str-truncated-100 append-bottom-0 d-block"> - {{ slotProps.result.external_url }} - </p> - </a> - </template> - </filtered-search-dropdown> - <template v-else> - <review-app-link - :link="deploymentExternalUrl" - css-class="js-deploy-url deploy-link btn btn-default btn-sm inline" - /> - </template> - <visual-review-app-link - v-if="showVisualReviewApp" - :link="deploymentExternalUrl" - :app-metadata="visualReviewAppMeta" - /> - </template> - <span - v-if="deployment.stop_url" - v-gl-tooltip - :title="deployInProgressTooltip" - class="d-inline-block" - tabindex="0" - > - <loading-button - :loading="isStopping" - :disabled="isDeployInProgress" - :title="__('Stop environment')" - container-class="js-stop-env btn btn-default btn-sm inline prepend-left-4" - @click="stopEnvironment" - > - <icon name="stop" /> - </loading-button> - </span> - </div> - </div> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js b/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js new file mode 100644 index 00000000000..90741e3aa44 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js @@ -0,0 +1,8 @@ +// DEPLOYMENT STATUSES +export const CREATED = 'created'; +export const MANUAL_DEPLOY = 'manual_deploy'; +export const WILL_DEPLOY = 'will_deploy'; +export const RUNNING = 'running'; +export const SUCCESS = 'success'; +export const FAILED = 'failed'; +export const CANCELED = 'canceled'; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue new file mode 100644 index 00000000000..e03b1e6d6a6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue @@ -0,0 +1,108 @@ +<script> +import { GlTooltipDirective } from '@gitlab/ui'; +import DeploymentInfo from './deployment_info.vue'; +import DeploymentViewButton from './deployment_view_button.vue'; +import DeploymentStopButton from './deployment_stop_button.vue'; +import { MANUAL_DEPLOY, WILL_DEPLOY, CREATED, RUNNING, SUCCESS } from './constants'; + +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: { + DeploymentInfo, + DeploymentStopButton, + DeploymentViewButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + deployment: { + type: Object, + required: true, + }, + showMetrics: { + type: Boolean, + required: true, + }, + showVisualReviewApp: { + type: Boolean, + required: false, + default: false, + }, + visualReviewAppMeta: { + type: Object, + required: false, + default: () => ({ + sourceProjectId: '', + sourceProjectPath: '', + mergeRequestId: '', + appUrl: '', + }), + }, + }, + computed: { + canBeManuallyDeployed() { + return this.computedDeploymentStatus === MANUAL_DEPLOY; + }, + computedDeploymentStatus() { + if (this.deployment.status === CREATED) { + return this.isManual ? MANUAL_DEPLOY : WILL_DEPLOY; + } + return this.deployment.status; + }, + hasExternalUrls() { + return Boolean(this.deployment.external_url && this.deployment.external_url_formatted); + }, + hasPreviousDeployment() { + return Boolean(!this.isCurrent && this.deployment.deployed_at); + }, + isCurrent() { + return this.computedDeploymentStatus === SUCCESS; + }, + isManual() { + return Boolean( + this.deployment.details && + this.deployment.details.playable_build && + this.deployment.details.playable_build.play_path, + ); + }, + isDeployInProgress() { + return this.deployment.status === RUNNING; + }, + }, +}; +</script> + +<template> + <div class="deploy-heading"> + <div class="ci-widget media"> + <div class="media-body"> + <div class="deploy-body"> + <deployment-info + :computed-deployment-status="computedDeploymentStatus" + :deployment="deployment" + :show-metrics="showMetrics" + /> + <div> + <!-- show appropriate version of review app button --> + <deployment-view-button + v-if="hasExternalUrls" + :is-current="isCurrent" + :deployment="deployment" + :show-visual-review-app="showVisualReviewApp" + :visual-review-app-metadata="visualReviewAppMeta" + /> + <!-- if it is stoppable, show stop --> + <deployment-stop-button + v-if="deployment.stop_url" + :is-deploy-in-progress="isDeployInProgress" + :stop-url="deployment.stop_url" + /> + </div> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue new file mode 100644 index 00000000000..db4a4ece002 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue @@ -0,0 +1,98 @@ +<script> +import { GlLink, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import MemoryUsage from './memory_usage.vue'; +import { MANUAL_DEPLOY, WILL_DEPLOY, RUNNING, SUCCESS, FAILED, CANCELED } from './constants'; + +export default { + name: 'DeploymentInfo', + components: { + GlLink, + MemoryUsage, + TooltipOnTruncate, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [timeagoMixin], + props: { + computedDeploymentStatus: { + type: String, + required: true, + }, + deployment: { + type: Object, + required: true, + }, + showMetrics: { + type: Boolean, + required: true, + }, + }, + deployedTextMap: { + [MANUAL_DEPLOY]: __('Can deploy manually to'), + [WILL_DEPLOY]: __('Will deploy to'), + [RUNNING]: __('Deploying to'), + [SUCCESS]: __('Deployed to'), + [FAILED]: __('Failed to deploy to'), + [CANCELED]: __('Canceled deploy to'), + }, + computed: { + deployTimeago() { + return this.timeFormatted(this.deployment.deployed_at); + }, + deployedText() { + return this.$options.deployedTextMap[this.computedDeploymentStatus]; + }, + hasDeploymentTime() { + return Boolean(this.deployment.deployed_at && this.deployment.deployed_at_formatted); + }, + hasDeploymentMeta() { + return Boolean(this.deployment.url && this.deployment.name); + }, + hasMetrics() { + return Boolean(this.deployment.metrics_url); + }, + showMemoryUsage() { + return this.hasMetrics && this.showMetrics; + }, + }, +}; +</script> + +<template> + <div class="js-deployment-info deployment-info"> + <template v-if="hasDeploymentMeta"> + <span>{{ deployedText }}</span> + <tooltip-on-truncate + :title="deployment.name" + truncate-target="child" + class="deploy-link label-truncate" + > + <gl-link + :href="deployment.url" + target="_blank" + rel="noopener noreferrer nofollow" + class="js-deploy-meta gl-font-size-12" + > + {{ deployment.name }} + </gl-link> + </tooltip-on-truncate> + </template> + <span + v-if="hasDeploymentTime" + v-gl-tooltip + :title="deployment.deployed_at_formatted" + class="js-deploy-time" + > + {{ deployTimeago }} + </span> + <memory-usage + v-if="showMemoryUsage" + :metrics-url="deployment.metrics_url" + :metrics-monitoring-url="deployment.metrics_monitoring_url" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_stop_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_stop_button.vue new file mode 100644 index 00000000000..e20296c41a2 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_stop_button.vue @@ -0,0 +1,83 @@ +<script> +import { GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import { visitUrl } from '~/lib/utils/url_utility'; +import createFlash from '~/flash'; +import MRWidgetService from '../../services/mr_widget_service'; + +export default { + name: 'DeploymentStopButton', + components: { + LoadingButton, + Icon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + isDeployInProgress: { + type: Boolean, + required: true, + }, + stopUrl: { + type: String, + required: true, + }, + }, + data() { + return { + isStopping: false, + }; + }, + computed: { + deployInProgressTooltip() { + return this.isDeployInProgress + ? __('Stopping this environment is currently not possible as a deployment is in progress') + : ''; + }, + }, + methods: { + stopEnvironment() { + const msg = __('Are you sure you want to stop this environment?'); + const isConfirmed = confirm(msg); // eslint-disable-line + + if (isConfirmed) { + this.isStopping = true; + + MRWidgetService.stopEnvironment(this.stopUrl) + .then(res => res.data) + .then(data => { + if (data.redirect_url) { + visitUrl(data.redirect_url); + } + + this.isStopping = false; + }) + .catch(() => { + createFlash( + __('Something went wrong while stopping this environment. Please try again.'), + ); + this.isStopping = false; + }); + } + }, + }, +}; +</script> + +<template> + <span v-gl-tooltip :title="deployInProgressTooltip" class="d-inline-block" tabindex="0"> + <loading-button + v-gl-tooltip + :loading="isStopping" + :disabled="isDeployInProgress" + :title="__('Stop environment')" + container-class="js-stop-env btn btn-default btn-sm inline prepend-left-4" + @click="stopEnvironment" + > + <icon name="stop" /> + </loading-button> + </span> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue new file mode 100644 index 00000000000..9965e3d5203 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue @@ -0,0 +1,99 @@ +<script> +import FilteredSearchDropdown from '~/vue_shared/components/filtered_search_dropdown.vue'; +import ReviewAppLink from '../review_app_link.vue'; + +export default { + name: 'DeploymentViewButton', + components: { + FilteredSearchDropdown, + ReviewAppLink, + VisualReviewAppLink: () => + import('ee_component/vue_merge_request_widget/components/visual_review_app_link.vue'), + }, + props: { + deployment: { + type: Object, + required: true, + }, + isCurrent: { + type: Boolean, + required: true, + }, + showVisualReviewApp: { + type: Boolean, + required: false, + default: false, + }, + visualReviewAppMeta: { + type: Object, + required: false, + default: () => ({ + sourceProjectId: '', + sourceProjectPath: '', + mergeRequestId: '', + appUrl: '', + }), + }, + }, + computed: { + deploymentExternalUrl() { + if (this.deployment.changes && this.deployment.changes.length === 1) { + return this.deployment.changes[0].external_url; + } + return this.deployment.external_url; + }, + shouldRenderDropdown() { + return this.deployment.changes && this.deployment.changes.length > 1; + }, + }, +}; +</script> + +<template> + <span> + <filtered-search-dropdown + v-if="shouldRenderDropdown" + class="js-mr-wigdet-deployment-dropdown inline" + :items="deployment.changes" + :main-action-link="deploymentExternalUrl" + filter-key="path" + > + <template slot="mainAction" slot-scope="slotProps"> + <review-app-link + :is-current="isCurrent" + :link="deploymentExternalUrl" + :css-class="`deploy-link js-deploy-url inline ${slotProps.className}`" + /> + </template> + + <template slot="result" slot-scope="slotProps"> + <a + :href="slotProps.result.external_url" + target="_blank" + rel="noopener noreferrer nofollow" + class="js-deploy-url-menu-item menu-item" + > + <strong class="str-truncated-100 append-bottom-0 d-block"> + {{ slotProps.result.path }} + </strong> + + <p class="text-secondary str-truncated-100 append-bottom-0 d-block"> + {{ slotProps.result.external_url }} + </p> + </a> + </template> + </filtered-search-dropdown> + <template v-else> + <review-app-link + :is-current="isCurrent" + :link="deploymentExternalUrl" + css-class="js-deploy-url deploy-link btn btn-default btn-sm inline" + /> + </template> + <visual-review-app-link + v-if="showVisualReviewApp" + :link="deploymentExternalUrl" + :app-metadata="visualReviewAppMeta" + /> + </span> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue index 7ce454b7338..fe41a15979e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue @@ -1,10 +1,10 @@ <script> import { sprintf, s__ } from '~/locale'; -import statusCodes from '../../lib/utils/http_status'; -import { bytesToMiB } from '../../lib/utils/number_utils'; -import { backOff } from '../../lib/utils/common_utils'; -import MemoryGraph from '../../vue_shared/components/memory_graph.vue'; -import MRWidgetService from '../services/mr_widget_service'; +import statusCodes from '~/lib/utils/http_status'; +import { bytesToMiB } from '~/lib/utils/number_utils'; +import { backOff } from '~/lib/utils/common_utils'; +import MemoryGraph from '~/vue_shared/components/memory_graph.vue'; +import MRWidgetService from '../../services/mr_widget_service'; export default { name: 'MemoryUsage', @@ -169,12 +169,6 @@ export default { <p v-if="shouldShowMetricsUnavailable" class="usage-info js-usage-info usage-info-unavailable"> {{ s__('mrWidget|Deployment statistics are not available currently') }} </p> - <memory-graph - v-if="shouldShowMemoryGraph" - :metrics="memoryMetrics" - :deployment-time="deploymentTime" - height="25" - width="100" - /> + <memory-graph v-if="shouldShowMemoryGraph" :metrics="memoryMetrics" :height="25" :width="110" /> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/loading.vue b/app/assets/javascripts/vue_merge_request_widget/components/loading.vue new file mode 100644 index 00000000000..78dc28ee92b --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/loading.vue @@ -0,0 +1,29 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; + +export default { + components: { + GlSkeletonLoader, + }, +}; +</script> + +<template> + <div class="prepend-top-default"> + <div class="mr-widget-heading p-3"> + <gl-skeleton-loader :width="577" :height="12"> + <rect width="86" height="12" rx="2" /> + <rect x="96" width="300" height="12" rx="2" /> + </gl-skeleton-loader> + </div> + <div class="mr-widget-heading mr-widget-workflow p-3"> + <gl-skeleton-loader :width="577" :height="72"> + <rect width="120" height="12" rx="2" /> + <rect y="20" width="300" height="12" rx="2" /> + <rect y="40" width="60" height="12" rx="2" /> + <rect y="40" x="68" width="100" height="12" rx="2" /> + <rect y="60" width="40" height="12" rx="2" /> + </gl-skeleton-loader> + </div> + </div> +</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 7c5f35579b8..42db1935123 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -1,12 +1,12 @@ <script> /* eslint-disable vue/require-default-prop */ import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import mrWidgetPipelineMixin from 'ee_else_ce/vue_merge_request_widget/mixins/mr_widget_pipeline'; import { sprintf, s__ } from '~/locale'; import PipelineStage from '~/pipelines/components/stage.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import Icon from '~/vue_shared/components/icon.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; -import mrWidgetPipelineMixin from 'ee_else_ce/vue_merge_request_widget/mixins/mr_widget_pipeline'; export default { name: 'MRWidgetPipeline', @@ -28,6 +28,10 @@ export default { type: Object, required: true, }, + pipelineCoverageDelta: { + type: String, + required: false, + }, // This prop needs to be camelCase, html attributes are case insensive // https://vuejs.org/v2/guide/components.html#camelCase-vs-kebab-case hasCi: { @@ -92,6 +96,16 @@ export default { showSourceBranch() { return Boolean(this.pipeline.ref.branch); }, + coverageDeltaClass() { + const delta = this.pipelineCoverageDelta; + if (delta && parseFloat(delta) > 0) { + return 'text-success'; + } + if (delta && parseFloat(delta) < 0) { + return 'text-danger'; + } + return ''; + }, }, }; </script> @@ -142,6 +156,14 @@ export default { </div> <div v-if="pipeline.coverage" class="coverage"> {{ s__('Pipeline|Coverage') }} {{ pipeline.coverage }}% + + <span + v-if="pipelineCoverageDelta" + class="js-pipeline-coverage-delta" + :class="coverageDeltaClass" + > + ({{ pipelineCoverageDelta }}%) + </span> </div> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue index ffc3e0967d4..90fb254ecca 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 @@ -1,9 +1,10 @@ <script> import _ from 'underscore'; import ArtifactsApp from './artifacts_list_app.vue'; -import Deployment from './deployment.vue'; +import Deployment from './deployment/deployment.vue'; import MrWidgetContainer from './mr_widget_container.vue'; import MrWidgetPipeline from './mr_widget_pipeline.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; /** * Renders the pipeline and related deployments from the store. @@ -23,6 +24,7 @@ export default { MergeTrainPositionIndicator: () => import('ee_component/vue_merge_request_widget/components/merge_train_position_indicator.vue'), }, + mixins: [glFeatureFlagsMixin()], props: { mr: { type: Object, @@ -62,7 +64,7 @@ export default { return this.isPostMerge ? this.mr.mergePipeline : this.mr.pipeline; }, showVisualReviewAppLink() { - return this.mr.visualReviewAppAvailable; + return this.mr.visualReviewAppAvailable && this.glFeatures.anonymousVisualReviewFeedback; }, showMergeTrainPositionIndicator() { return _.isNumber(this.mr.mergeTrainIndex); @@ -74,6 +76,7 @@ export default { <mr-widget-container> <mr-widget-pipeline :pipeline="pipeline" + :pipeline-coverage-delta="mr.pipelineCoverageDelta" :ci-status="mr.ciStatus" :has-ci="mr.hasCI" :source-branch="branch" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue index 75f557d05dd..1550ec0f21e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue @@ -1,4 +1,5 @@ <script> +import { __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; export default { @@ -6,19 +7,35 @@ export default { Icon, }, props: { - link: { + cssClass: { type: String, required: true, }, - cssClass: { + isCurrent: { + type: Boolean, + required: true, + }, + link: { type: String, required: true, }, }, + computed: { + linkText() { + return this.isCurrent ? __('View app') : __('View previous app'); + }, + }, }; </script> <template> - <a :href="link" target="_blank" rel="noopener noreferrer nofollow" :class="cssClass"> - {{ __('View app') }} <icon class="fgray" name="external-link" /> + <a + :href="link" + target="_blank" + rel="noopener noreferrer nofollow" + :class="cssClass" + data-track-event="open_review_app" + data-track-label="review_app" + > + {{ linkText }} <icon class="fgray" name="external-link" /> </a> </template> 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 a2b5a79af36..c8e652a1305 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,5 +1,6 @@ <script> /* eslint-disable @gitlab/vue-i18n/no-bare-strings */ +import { GlLoadingIcon } from '@gitlab/ui'; import Flash from '~/flash'; import tooltip from '~/vue_shared/directives/tooltip'; import { s__, __ } from '~/locale'; @@ -7,7 +8,6 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import MrWidgetAuthorTime from '../../components/mr_widget_author_time.vue'; import statusIcon from '../mr_widget_status_icon.vue'; import eventHub from '../../event_hub'; -import { GlLoadingIcon } from '@gitlab/ui'; export default { name: 'MRWidgetMerged', @@ -155,7 +155,7 @@ export default { {{ cherryPickLabel }} </a> </div> - <section class="mr-info-list"> + <section class="mr-info-list" data-qa-selector="merged_status_content"> <p> {{ s__('mrWidget|The changes were merged into') }} <span class="label-branch"> 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 2c113770d8b..d230ac566de 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 @@ -1,10 +1,11 @@ <script> import _ from 'underscore'; +import { GlIcon } from '@gitlab/ui'; import successSvg from 'icons/_icon_status_success.svg'; import warningSvg from 'icons/_icon_status_warning.svg'; -import simplePoll from '~/lib/utils/simple_poll'; -import { __ } from '~/locale'; import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge'; +import simplePoll from '~/lib/utils/simple_poll'; +import { __, sprintf } from '~/locale'; import MergeRequest from '../../../merge_request'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import Flash from '../../../flash'; @@ -24,6 +25,11 @@ export default { CommitsHeader, CommitEdit, CommitMessageDropdown, + GlIcon, + MergeImmediatelyConfirmationDialog: () => + import( + 'ee_component/vue_merge_request_widget/components/merge_immediately_confirmation_dialog.vue' + ), }, mixins: [readyToMergeMixin], props: { @@ -111,6 +117,18 @@ export default { shouldShowMergeEdit() { return !this.mr.ffOnlyEnabled; }, + shaMismatchLink() { + const href = this.mr.mergeRequestDiffsPath; + + return sprintf( + __('New changes were added. %{linkStart}Reload the page to review them%{linkEnd}'), + { + linkStart: `<a href="${href}">`, + linkEnd: '</a>', + }, + false, + ); + }, }, methods: { updateMergeCommitMessage(includeDescription) { @@ -123,7 +141,7 @@ export default { } const options = { - sha: this.mr.sha, + sha: this.mr.latestSHA || this.mr.sha, commit_message: this.commitMessage, auto_merge_strategy: useAutoMerge ? this.mr.preferredAutoMergeStrategy : undefined, should_remove_source_branch: this.removeSourceBranch === true, @@ -151,6 +169,16 @@ export default { new Flash(__('Something went wrong. Please try again.')); // eslint-disable-line }); }, + handleMergeImmediatelyButtonClick() { + if (this.isMergeImmediatelyDangerous) { + this.$refs.confirmationDialog.show(); + } else { + this.handleMergeButtonClick(false, true); + } + }, + onMergeImmediatelyConfirmation() { + this.handleMergeButtonClick(false, true); + }, initiateMergePolling() { simplePoll( (continuePolling, stopPolling) => { @@ -249,9 +277,10 @@ export default { type="button" class="btn btn-sm btn-info dropdown-toggle js-merge-moment" data-toggle="dropdown" + data-qa-selector="merge_moment_dropdown" :aria-label="__('Select merge moment')" > - <i class="fa fa-chevron-down qa-merge-moment-dropdown" aria-hidden="true"></i> + <i class="fa fa-chevron-down" aria-hidden="true"></i> </button> <ul v-if="shouldShowMergeImmediatelyDropdown" @@ -271,10 +300,16 @@ export default { </a> </li> <li> + <merge-immediately-confirmation-dialog + ref="confirmationDialog" + :docs-url="mr.mergeImmediatelyDocsPath" + @mergeImmediately="onMergeImmediatelyConfirmation" + /> <a - class="accept-merge-request qa-merge-immediately-option" + class="accept-merge-request js-merge-immediately-button" + data-qa-selector="merge_immediately_option" href="#" - @click.prevent="handleMergeButtonClick(false, true)" + @click.prevent="handleMergeImmediatelyButtonClick" > <span class="media"> <span class="merge-opt-icon" aria-hidden="true" v-html="warningSvg"></span> @@ -312,6 +347,10 @@ export default { </template> </div> </div> + <div v-if="mr.isSHAMismatch" class="d-flex align-items-center mt-2 js-sha-mismatch"> + <gl-icon name="warning-solid" class="text-warning mr-1" /> + <span class="text-warning" v-html="shaMismatchLink"></span> + </div> </div> </div> <template v-if="shouldShowMergeControls"> 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 40e6203599f..32a2b7b83f4 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 @@ -23,5 +23,8 @@ export default { shouldShowMergeImmediatelyDropdown() { return this.mr.isPipelineActive && !this.mr.onlyAllowMergeIfPipelineSucceeds; }, + isMergeImmediatelyDangerous() { + return false; + }, }, }; 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 edd21a81f8b..38a7c262b3e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -1,16 +1,17 @@ <script> import _ from 'underscore'; -import { sprintf, s__, __ } from '~/locale'; -import Project from '~/pages/projects/project'; -import SmartInterval from '~/smart_interval'; import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store'; import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service'; import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps'; +import { sprintf, s__, __ } from '~/locale'; +import Project from '~/pages/projects/project'; +import SmartInterval from '~/smart_interval'; import createFlash from '../flash'; +import Loading from './components/loading.vue'; import WidgetHeader from './components/mr_widget_header.vue'; import WidgetMergeHelp from './components/mr_widget_merge_help.vue'; import MrWidgetPipelineContainer from './components/mr_widget_pipeline_container.vue'; -import Deployment from './components/deployment.vue'; +import Deployment from './components/deployment/deployment.vue'; import WidgetRelatedLinks from './components/mr_widget_related_links.vue'; import MrWidgetAlertMessage from './components/mr_widget_alert_message.vue'; import MergedState from './components/states/mr_widget_merged.vue'; @@ -24,7 +25,6 @@ import NothingToMergeState from './components/states/nothing_to_merge.vue'; import MissingBranchState from './components/states/mr_widget_missing_branch.vue'; import NotAllowedState from './components/states/mr_widget_not_allowed.vue'; import ReadyToMergeState from './components/states/ready_to_merge.vue'; -import ShaMismatchState from './components/states/sha_mismatch.vue'; import UnresolvedDiscussionsState from './components/states/unresolved_discussions.vue'; import PipelineBlockedState from './components/states/mr_widget_pipeline_blocked.vue'; import PipelineFailedState from './components/states/pipeline_failed.vue'; @@ -44,6 +44,7 @@ export default { // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings name: 'MRWidget', components: { + Loading, 'mr-widget-header': WidgetHeader, 'mr-widget-merge-help': WidgetMergeHelp, MrWidgetPipelineContainer, @@ -61,7 +62,7 @@ export default { 'mr-widget-not-allowed': NotAllowedState, 'mr-widget-missing-branch': MissingBranchState, 'mr-widget-ready-to-merge': ReadyToMergeState, - 'sha-mismatch': ShaMismatchState, + 'sha-mismatch': ReadyToMergeState, 'mr-widget-checking': CheckingState, 'mr-widget-unresolved-discussions': UnresolvedDiscussionsState, 'mr-widget-pipeline-blocked': PipelineBlockedState, @@ -80,12 +81,12 @@ export default { }, }, data() { - const store = new MRWidgetStore(this.mrData || window.gl.mrWidgetData); - const service = this.createService(store); + const store = this.mrData && new MRWidgetStore(this.mrData); + return { mr: store, - state: store.state, - service, + state: store && store.state, + service: store && this.createService(store), }; }, computed: { @@ -133,29 +134,58 @@ export default { } }, }, - created() { - this.initPolling(); - this.bindEventHubListeners(); - eventHub.$on('mr.discussion.updated', this.checkStatus); - }, mounted() { - this.setFaviconHelper(); - this.initDeploymentsPolling(); - - if (this.shouldRenderMergedPipeline) { - this.initPostMergeDeploymentsPolling(); + if (gon && gon.features && gon.features.asyncMrWidget) { + MRWidgetService.fetchInitialData() + .then(({ data }) => this.initWidget(data)) + .catch(() => + createFlash(__('Unable to load the merge request widget. Try reloading the page.')), + ); + } else { + this.initWidget(); } }, beforeDestroy() { eventHub.$off('mr.discussion.updated', this.checkStatus); - this.pollingInterval.destroy(); - this.deploymentsInterval.destroy(); + if (this.pollingInterval) { + this.pollingInterval.destroy(); + } + + if (this.deploymentsInterval) { + this.deploymentsInterval.destroy(); + } if (this.postMergeDeploymentsInterval) { this.postMergeDeploymentsInterval.destroy(); } }, methods: { + initWidget(data = {}) { + if (this.mr) { + this.mr.setData({ ...window.gl.mrWidgetData, ...data }); + } else { + this.mr = new MRWidgetStore({ ...window.gl.mrWidgetData, ...data }); + } + + if (!this.state) { + this.state = this.mr.state; + } + + if (!this.service) { + this.service = this.createService(this.mr); + } + + this.setFaviconHelper(); + this.initDeploymentsPolling(); + + if (this.shouldRenderMergedPipeline) { + this.initPostMergeDeploymentsPolling(); + } + + this.initPolling(); + this.bindEventHubListeners(); + eventHub.$on('mr.discussion.updated', this.checkStatus); + }, getServiceEndpoints(store) { return { mergePath: store.mergePath, @@ -319,7 +349,7 @@ export default { }; </script> <template> - <div class="mr-state-widget prepend-top-default"> + <div v-if="mr" class="mr-state-widget prepend-top-default"> <mr-widget-header :mr="mr" /> <mr-widget-pipeline-container v-if="shouldRenderPipelines" @@ -377,4 +407,5 @@ export default { :is-post-merge="true" /> </div> + <loading v-else /> </template> 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 8a229d80954..d22cb4ced80 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 @@ -61,4 +61,11 @@ export default class MRWidgetService { static fetchMetrics(metricsUrl) { return axios.get(`${metricsUrl}.json`); } + + static fetchInitialData() { + return Promise.all([ + axios.get(window.gl.mrWidgetData.merge_request_cached_widget_path), + axios.get(window.gl.mrWidgetData.merge_request_widget_path), + ]).then(axios.spread((res, cachedRes) => ({ data: Object.assign(res.data, cachedRes.data) }))); + } } 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 f51d0fa4f52..c7949fa264e 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 @@ -1,4 +1,4 @@ -import Timeago from 'timeago.js'; +import { format } from 'timeago.js'; import _ from 'underscore'; import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key'; import { stateKey } from './state_maps'; @@ -42,12 +42,14 @@ export default class MergeRequestStore { this.commitsCount = data.commits_count; this.divergedCommitsCount = data.diverged_commits_count; this.pipeline = data.pipeline || {}; + this.pipelineCoverageDelta = data.pipeline_coverage_delta; this.mergePipeline = data.merge_pipeline || {}; this.deployments = this.deployments || data.deployments || []; this.postMergeDeployments = this.postMergeDeployments || []; this.commits = data.commits_without_merge_commits || []; this.squashCommitMessage = data.default_squash_commit_message; this.rebaseInProgress = data.rebase_in_progress; + this.mergeRequestDiffsPath = data.diffs_path; if (data.issues_links) { const links = data.issues_links; @@ -81,6 +83,7 @@ export default class MergeRequestStore { this.isOpen = data.state === 'opened'; this.hasMergeableDiscussionsState = data.mergeable_discussions_state === false; this.isSHAMismatch = this.sha !== data.diff_head_sha; + this.latestSHA = data.diff_head_sha; this.canBeMerged = data.can_be_merged || false; this.isMergeAllowed = data.mergeable || false; this.mergeOngoing = data.merge_ongoing; @@ -170,6 +173,8 @@ export default class MergeRequestStore { this.conflictsDocsPath = data.conflicts_docs_path; this.ciEnvironmentsStatusPath = data.ci_environments_status_path; this.securityApprovalsHelpPagePath = data.security_approvals_help_page_path; + this.eligibleApproversDocsPath = data.eligible_approvers_docs_path; + this.mergeImmediatelyDocsPath = data.merge_immediately_docs_path; } get isNothingToMergeState() { @@ -213,9 +218,7 @@ export default class MergeRequestStore { return ''; } - const timeagoInstance = new Timeago(); - - return timeagoInstance.format(date); + return format(date); } static getPreferredAutoMergeStrategy(availableAutoMergeStrategies) { diff --git a/app/assets/javascripts/vue_shared/components/bar_chart.vue b/app/assets/javascripts/vue_shared/components/bar_chart.vue index eabf5d4bf60..25d7bfe515c 100644 --- a/app/assets/javascripts/vue_shared/components/bar_chart.vue +++ b/app/assets/javascripts/vue_shared/components/bar_chart.vue @@ -55,13 +55,13 @@ export default { vbWidth: 0, vbHeight: 0, vpWidth: 0, - vpHeight: 350, - preserveAspectRatioType: 'xMidYMid meet', + vpHeight: 200, + preserveAspectRatioType: 'xMidYMin meet', containerMargin: { leftRight: 30, }, viewBoxMargin: { - topBottom: 150, + topBottom: 100, }, panX: 0, xScale: {}, @@ -274,6 +274,7 @@ export default { <div ref="svgContainer" :class="activateGrabCursor" class="svg-graph-container"> <svg ref="baseSvg" + class="svg-graph overflow-visible pt-5" :width="vpWidth" :height="vpHeight" :viewBox="svgViewBox" diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue index c50304f057d..eb3e489fb8c 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue @@ -1,8 +1,8 @@ <script> -import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; import $ from 'jquery'; import { GlSkeletonLoading } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; const { CancelToken } = axios; let axiosSource; diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue index 22f370c4bca..494df2d7a37 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue @@ -1,6 +1,6 @@ <script> -import { __ } from '~/locale'; import { GlLoadingIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; export default { components: { diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue index c01c7cc4ccc..610bce9a705 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue @@ -8,6 +8,11 @@ export default { required: true, default: __('Search'), }, + focused: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { searchQuery: this.value }; @@ -16,6 +21,11 @@ export default { searchQuery(query) { this.$emit('input', query); }, + focused(val) { + if (val) { + this.$refs.searchInput.focus(); + } + }, }, }; </script> @@ -23,6 +33,7 @@ export default { <template> <div class="dropdown-input"> <input + ref="searchInput" v-model="searchQuery" :placeholder="placeholderText" class="dropdown-input-field" diff --git a/app/assets/javascripts/vue_shared/components/expand_button.vue b/app/assets/javascripts/vue_shared/components/expand_button.vue index d64ab774431..e2a6e92081f 100644 --- a/app/assets/javascripts/vue_shared/components/expand_button.vue +++ b/app/assets/javascripts/vue_shared/components/expand_button.vue @@ -1,4 +1,5 @@ <script> +import { GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; @@ -15,6 +16,7 @@ import Icon from '~/vue_shared/components/icon.vue'; export default { name: 'ExpandButton', components: { + GlButton, Icon, }, data() { @@ -39,15 +41,25 @@ export default { </script> <template> <span> - <button + <gl-button v-show="isCollapsed" :aria-label="ariaLabel" type="button" - class="text-expander btn-blank" + class="js-text-expander-prepend text-expander btn-blank" @click="onClick" > <icon :size="12" name="ellipsis_h" /> - </button> + </gl-button> + <span v-if="isCollapsed"> <slot name="short"></slot> </span> <span v-if="!isCollapsed"> <slot name="expanded"></slot> </span> + <gl-button + v-show="!isCollapsed" + :aria-label="ariaLabel" + type="button" + class="js-text-expander-append text-expander btn-blank" + @click="onClick" + > + <icon :size="12" name="ellipsis_h" /> + </gl-button> </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/gl_countdown.vue b/app/assets/javascripts/vue_shared/components/gl_countdown.vue index 4aae3549601..1769a283d8c 100644 --- a/app/assets/javascripts/vue_shared/components/gl_countdown.vue +++ b/app/assets/javascripts/vue_shared/components/gl_countdown.vue @@ -1,6 +1,6 @@ <script> -import { calculateRemainingMilliseconds, formatTime } from '~/lib/utils/datetime_utility'; import { GlTooltipDirective } from '@gitlab/ui'; +import { calculateRemainingMilliseconds, formatTime } from '~/lib/utils/datetime_utility'; /** * Counts down to a given end date. diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue index 9b2ee5062b1..cfbc5b0df3c 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue +++ b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue @@ -54,7 +54,7 @@ export default { return timeFor( this.milestoneDue, sprintf(__('Expired %{expiredOn}'), { - expiredOn: this.timeFormated(this.milestoneDue), + expiredOn: this.timeFormatted(this.milestoneDue), }), ); } @@ -62,7 +62,7 @@ export default { return sprintf( this.isMilestoneStarted ? __('Started %{startsIn}') : __('Starts %{startsIn}'), { - startsIn: this.timeFormated(this.milestoneStart), + startsIn: this.timeFormatted(this.milestoneStart), }, ); } 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 cc700440a23..8a8cf09194c 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -1,6 +1,6 @@ <script> -import Icon from '~/vue_shared/components/icon.vue'; import { GlButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; export default { components: { Icon, GlButton, GlLoadingIcon }, diff --git a/app/assets/javascripts/vue_shared/components/memory_graph.vue b/app/assets/javascripts/vue_shared/components/memory_graph.vue index 26d7d8e8866..af0b85cc6e4 100644 --- a/app/assets/javascripts/vue_shared/components/memory_graph.vue +++ b/app/assets/javascripts/vue_shared/components/memory_graph.vue @@ -1,128 +1,43 @@ <script> -import { __, sprintf } from '~/locale'; -import { getTimeago } from '../../lib/utils/datetime_utility'; +import { GlSparklineChart } from '@gitlab/ui/dist/charts'; +import { formatDate, secondsToMilliseconds } from '~/lib/utils/datetime_utility'; export default { name: 'MemoryGraph', + components: { + GlSparklineChart, + }, props: { metrics: { type: Array, required: true }, - deploymentTime: { type: Number, required: true }, - width: { type: String, required: true }, - height: { type: String, required: true }, - }, - data() { - return { - pathD: '', - pathViewBox: '', - dotX: '', - dotY: '', - }; + width: { type: Number, required: true }, + height: { type: Number, required: true }, }, computed: { - getFormattedMedian() { - const deployedSince = getTimeago().format(this.deploymentTime * 1000); - return sprintf(__('Deployed %{deployedSince}'), { deployedSince }); + chartData() { + return this.metrics.map(([x, y]) => [ + this.getFormattedDeploymentTime(x), + this.getMemoryUsage(y), + ]); }, }, - mounted() { - this.renderGraph(this.deploymentTime, this.metrics); - }, methods: { - /** - * Returns metric value index in metrics array - * with timestamp closest to matching median - */ - getMedianMetricIndex(median, metrics) { - let matchIndex = 0; - let timestampDiff = 0; - let smallestDiff = 0; - - const metricTimestamps = metrics.map(v => v[0]); - - // Find metric timestamp which is closest to deploymentTime - timestampDiff = Math.abs(metricTimestamps[0] - median); - metricTimestamps.forEach((timestamp, index) => { - if (index === 0) { - // Skip first element - return; - } - - smallestDiff = Math.abs(timestamp - median); - if (smallestDiff < timestampDiff) { - matchIndex = index; - timestampDiff = smallestDiff; - } - }); - - return matchIndex; + getFormattedDeploymentTime(timestamp) { + return formatDate(new Date(secondsToMilliseconds(timestamp)), 'mmm dd yyyy HH:MM:s'); }, - - /** - * Get Graph Plotting values to render Line and Dot - */ - getGraphPlotValues(median, metrics) { - const renderData = metrics.map(v => v[1]); - const medianMetricIndex = this.getMedianMetricIndex(median, metrics); - let cx = 0; - let cy = 0; - - // Find Maximum and Minimum values from `renderData` array - const maxMemory = Math.max.apply(null, renderData); - const minMemory = Math.min.apply(null, renderData); - - // Find difference between extreme ends - const diff = maxMemory - minMemory; - const lineWidth = renderData.length; - - // Iterate over metrics values and perform following - // 1. Find x & y co-ords for deploymentTime's memory value - // 2. Return line path against maxMemory - const linePath = renderData.map((y, x) => { - if (medianMetricIndex === x) { - cx = x; - cy = maxMemory - y; - } - return `${x} ${maxMemory - y}`; - }); - - return { - pathD: linePath, - pathViewBox: { - lineWidth, - diff, - }, - dotX: cx, - dotY: cy, - }; - }, - - /** - * Render Graph based on provided median and metrics values - */ - renderGraph(median, metrics) { - const { pathD, pathViewBox, dotX, dotY } = this.getGraphPlotValues(median, metrics); - - // Set props and update graph on UI. - this.pathD = `M ${pathD}`; - this.pathViewBox = `0 0 ${pathViewBox.lineWidth} ${pathViewBox.diff}`; - this.dotX = dotX; - this.dotY = dotY; + getMemoryUsage(MBs) { + return Number(MBs).toFixed(2); }, }, }; </script> <template> - <div class="memory-graph-container"> - <svg - :title="getFormattedMedian" - :width="width" + <div class="memory-graph-container p-1" :style="{ width: `${width}px` }"> + <gl-sparkline-chart :height="height" - class="has-tooltip" - xmlns="http://www.w3.org/2000/svg" - > - <path :d="pathD" :viewBox="pathViewBox" /> - <circle :cx="dotX" :cy="dotY" r="1.5" transform="translate(0 -1)" /> - </svg> + :tooltip-label="__('MB')" + :show-last-y-value="false" + :data="chartData" + /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue index edbeab9c600..cdcfff42981 100644 --- a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue +++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue @@ -1,9 +1,9 @@ <script> import $ from 'jquery'; import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import Clipboard from 'clipboard'; import { __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; -import Clipboard from 'clipboard'; export default { components: { diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index f8e010c4f42..15ca64ba297 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -19,9 +19,9 @@ import $ from 'jquery'; import { mapGetters, mapActions } from 'vuex'; import { GlSkeletonLoading } from '@gitlab/ui'; +import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history'; import noteHeader from '~/notes/components/note_header.vue'; import Icon from '~/vue_shared/components/icon.vue'; -import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history'; import TimelineEntryItem from './timeline_entry_item.vue'; import { spriteIcon } from '../../../lib/utils/common_utils'; import initMRPopovers from '~/mr_popover/'; diff --git a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue index e89638130f5..29a4a90a59f 100644 --- a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue +++ b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue @@ -1,15 +1,18 @@ <script> +import { GlPagination } from '@gitlab/ui'; import { - PAGINATION_UI_BUTTON_LIMIT, - UI_LIMIT, - SPREAD, PREV, NEXT, - FIRST, - LAST, + LABEL_FIRST_PAGE, + LABEL_PREV_PAGE, + LABEL_NEXT_PAGE, + LABEL_LAST_PAGE, } from '~/vue_shared/components/pagination/constants'; export default { + components: { + GlPagination, + }, props: { /** This function will take the information given by the pagination component @@ -46,113 +49,34 @@ export default { }, }, computed: { - prev() { - return this.pageInfo.previousPage; - }, - next() { - return this.pageInfo.nextPage; - }, - getItems() { - const { totalPages, nextPage, previousPage, page } = this.pageInfo; - const items = []; - - if (page > 1) { - items.push({ title: FIRST, first: true }); - } - - if (previousPage) { - items.push({ title: PREV, prev: true }); - } else { - items.push({ title: PREV, disabled: true, prev: true }); - } - - if (page > UI_LIMIT) items.push({ title: SPREAD, separator: true }); - - if (totalPages) { - const start = Math.max(page - PAGINATION_UI_BUTTON_LIMIT, 1); - const end = Math.min(page + PAGINATION_UI_BUTTON_LIMIT, totalPages); - - for (let i = start; i <= end; i += 1) { - const isActive = i === page; - items.push({ title: i, active: isActive, page: true }); - } - - if (totalPages - page > PAGINATION_UI_BUTTON_LIMIT) { - items.push({ title: SPREAD, separator: true, page: true }); - } - } - - if (nextPage) { - items.push({ title: NEXT, next: true }); - } else { - items.push({ title: NEXT, disabled: true, next: true }); - } - - if (totalPages && totalPages - page >= 1) { - items.push({ title: LAST, last: true }); - } - - return items; - }, showPagination() { return this.pageInfo.nextPage || this.pageInfo.previousPage; }, }, - methods: { - changePage(text, isDisabled) { - if (isDisabled) return; - - const { totalPages, nextPage, previousPage } = this.pageInfo; - - switch (text) { - case SPREAD: - break; - case LAST: - this.change(totalPages); - break; - case NEXT: - this.change(nextPage); - break; - case PREV: - this.change(previousPage); - break; - case FIRST: - this.change(1); - break; - default: - this.change(Number(text)); - break; - } - }, - hideOnSmallScreen(item) { - return !item.first && !item.last && !item.next && !item.prev && !item.active; - }, - }, + prevText: PREV, + nextText: NEXT, + labelFirstPage: LABEL_FIRST_PAGE, + labelPrevPage: LABEL_PREV_PAGE, + labelNextPage: LABEL_NEXT_PAGE, + labelLastPage: LABEL_LAST_PAGE, }; </script> <template> - <div v-if="showPagination" class="gl-pagination prepend-top-default"> - <ul class="pagination justify-content-center"> - <li - v-for="(item, index) in getItems" - :key="index" - :class="{ - page: item.page, - 'js-previous-button': item.prev, - 'js-next-button': item.next, - 'js-last-button': item.last, - 'js-first-button': item.first, - 'd-none d-md-block': hideOnSmallScreen(item), - separator: item.separator, - active: item.active, - disabled: item.disabled || item.separator, - }" - class="page-item" - > - <button type="button" class="page-link" @click="changePage(item.title, item.disabled)"> - {{ item.title }} - </button> - </li> - </ul> - </div> + <gl-pagination + v-if="showPagination" + class="justify-content-center prepend-top-default" + v-bind="$attrs" + :value="pageInfo.page" + :per-page="pageInfo.perPage" + :total-items="pageInfo.total" + :prev-page="pageInfo.previousPage" + :prev-text="$options.prevText" + :next-page="pageInfo.nextPage" + :next-text="$options.nextText" + :label-first-page="$options.labelFirstPage" + :label-prev-page="$options.labelPrevPage" + :label-next-page="$options.labelNextPage" + :label-last-page="$options.labelLastPage" + @input="change" + /> </template> diff --git a/app/assets/javascripts/vue_shared/components/project_avatar/image.vue b/app/assets/javascripts/vue_shared/components/project_avatar/image.vue index 43bbb756805..269736c799c 100644 --- a/app/assets/javascripts/vue_shared/components/project_avatar/image.vue +++ b/app/assets/javascripts/vue_shared/components/project_avatar/image.vue @@ -14,8 +14,8 @@ /> */ -import { __ } from '~/locale'; import defaultAvatarUrl from 'images/no_avatar.png'; +import { __ } from '~/locale'; import { placeholderImage } from '../../../lazy_loader'; export default { diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue index 071bae7f665..c472e54efda 100644 --- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue @@ -1,10 +1,10 @@ <script> import { GlButton } from '@gitlab/ui'; +import _ from 'underscore'; import Icon from '~/vue_shared/components/icon.vue'; import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue'; import highlight from '~/lib/utils/highlight'; import { truncateNamespace } from '~/lib/utils/text_utility'; -import _ from 'underscore'; export default { name: 'ProjectListItem', diff --git a/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue b/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue index 1f3d248e991..02cb7785ef4 100644 --- a/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue +++ b/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue @@ -1,6 +1,6 @@ <script> -import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; import $ from 'jquery'; +import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; export default { data() { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue index c1f3d86335a..80c61627b8f 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue @@ -64,7 +64,7 @@ export default { tooltipText(dateType = 'min') { const defaultText = dateType === 'min' ? __('Start date') : __('Due date'); const date = this[`${dateType}Date`]; - const timeAgo = dateType === 'min' ? this.timeFormated(date) : timeFor(date); + const timeAgo = dateType === 'min' ? this.timeFormatted(date) : timeFor(date); const dateText = date ? [this.dateText(dateType), `(${timeAgo})`].join(' ') : ''; if (date) { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue index 13795eff714..0e401a9f7aa 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue @@ -1,10 +1,10 @@ <script> import $ from 'jquery'; +import { GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import LabelsSelect from '~/labels_select'; import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; -import { GlLoadingIcon } from '@gitlab/ui'; import DropdownTitle from './dropdown_title.vue'; import DropdownValue from './dropdown_value.vue'; import DropdownValueCollapsed from './dropdown_value_collapsed.vue'; diff --git a/app/assets/javascripts/vue_shared/components/split_button.vue b/app/assets/javascripts/vue_shared/components/split_button.vue index f7dc00a345c..9aacde49264 100644 --- a/app/assets/javascripts/vue_shared/components/split_button.vue +++ b/app/assets/javascripts/vue_shared/components/split_button.vue @@ -26,6 +26,11 @@ export default { required: false, default: '', }, + variant: { + type: String, + required: false, + default: 'secondary', + }, }, data() { @@ -53,6 +58,7 @@ export default { :menu-class="`dropdown-menu-selectable ${menuClass}`" split :text="dropdownToggleText" + :variant="variant" v-bind="$attrs" @click="triggerEvent" > 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 af4eb2de7f8..ea564d1b2f2 100644 --- a/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue +++ b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue @@ -71,6 +71,10 @@ export default { }, methods: { getPercent(count) { + if (!this.totalCount) { + return 0; + } + const percent = roundOffFloat((count / this.totalCount) * 100, 1); if (percent > 0 && percent < 1) { return '< 1'; diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue index 43935cf31d5..b1a4f3dccaf 100644 --- a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue +++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue @@ -35,7 +35,7 @@ export default { v-gl-tooltip.viewport="{ placement: tooltipPlacement }" :class="cssClass" :title="tooltipTitle(time)" - v-text="timeFormated(time)" + v-text="timeFormatted(time)" > </time> </template> diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js index 3c727cb7b3f..fbebd7c7945 100644 --- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js +++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js @@ -159,7 +159,7 @@ const mixins = { return this.displayReference.split(this.pathIdSeparator).pop(); }, createdAtInWords() { - return this.createdAt ? this.timeFormated(this.createdAt) : ''; + return this.createdAt ? this.timeFormatted(this.createdAt) : ''; }, createdAtTimestamp() { return this.createdAt ? formatDate(new Date(this.createdAt)) : ''; @@ -168,10 +168,10 @@ const mixins = { return this.mergedAt ? formatDate(new Date(this.mergedAt)) : ''; }, mergedAtInWords() { - return this.mergedAt ? this.timeFormated(this.mergedAt) : ''; + return this.mergedAt ? this.timeFormatted(this.mergedAt) : ''; }, closedAtInWords() { - return this.closedAt ? this.timeFormated(this.closedAt) : ''; + return this.closedAt ? this.timeFormatted(this.closedAt) : ''; }, closedAtTimestamp() { return this.closedAt ? formatDate(new Date(this.closedAt)) : ''; diff --git a/app/assets/javascripts/vue_shared/mixins/timeago.js b/app/assets/javascripts/vue_shared/mixins/timeago.js index 4e3b9d7b767..af14c6d9486 100644 --- a/app/assets/javascripts/vue_shared/mixins/timeago.js +++ b/app/assets/javascripts/vue_shared/mixins/timeago.js @@ -5,7 +5,7 @@ import { formatDate, getTimeago } from '../../lib/utils/datetime_utility'; */ export default { methods: { - timeFormated(time) { + timeFormatted(time) { const timeago = getTimeago(); return timeago.format(time); diff --git a/app/assets/stylesheets/components/release_block_milestone_info.scss b/app/assets/stylesheets/components/release_block_milestone_info.scss new file mode 100644 index 00000000000..b6a85ae965a --- /dev/null +++ b/app/assets/stylesheets/components/release_block_milestone_info.scss @@ -0,0 +1,6 @@ +.release-block-milestone-info { + .milestone-progress-bar-container { + width: 300px; + min-height: 46px; + } +} diff --git a/app/assets/stylesheets/components/toast.scss b/app/assets/stylesheets/components/toast.scss deleted file mode 100644 index e27bf282247..00000000000 --- a/app/assets/stylesheets/components/toast.scss +++ /dev/null @@ -1,52 +0,0 @@ -/* -* These styles are specific to the gl-toast component. -* Documentation: https://design.gitlab.com/components/toasts -* Note: Styles below are nested in order to override some of vue-toasted's default styling -*/ -.toasted-container { - - max-width: $toast-max-width; - - @include media-breakpoint-down(xs) { - width: 100%; - padding-right: $toast-padding-right; - } - - .toasted.gl-toast { - border-radius: $border-radius-default; - font-size: $gl-font-size; - padding: $gl-padding-8 $gl-padding $gl-padding-8 $gl-padding-24; - margin-top: $toast-default-margin; - line-height: $gl-line-height; - background-color: rgba($gray-900, $toast-background-opacity); - - span { - padding-right: $gl-padding-8; - } - - @include media-breakpoint-down(xs) { - .action:first-of-type { - // Ensures actions buttons are right aligned on mobile - margin-left: auto; - } - } - - .action { - color: $blue-300; - margin: 0 0 0 $toast-default-margin; - text-transform: none; - font-size: $gl-font-size; - } - - .toast-close { - font-size: $default-icon-size; - margin-left: $toast-default-margin; - } - } -} - -// Overrides the default positioning of toasts -body .toasted-container.bottom-left { - bottom: $toast-offset; - left: $toast-offset; -} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 31ea59df4c5..4b7dda3a2ff 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -433,6 +433,7 @@ img.emoji { .block { display: block; } .flex { display: flex; } .vertical-align-top { vertical-align: top; } +.vertical-align-text-top { vertical-align: text-top; } .vertical-align-middle { vertical-align: middle; } .vertical-align-sub { vertical-align: sub; } .flex-align-self-center { align-self: center; } @@ -442,6 +443,7 @@ img.emoji { .ws-normal { white-space: normal; } .ws-pre-wrap { white-space: pre-wrap; } .overflow-auto { overflow: auto; } +.overflow-visible { overflow: visible; } .d-flex-center { display: flex; @@ -514,6 +516,12 @@ img.emoji { cursor: pointer; } +// this needs to use "!important" due to some very specific styles +// around buttons +.cursor-default { + cursor: default !important; +} + // Make buttons/dropdowns full-width on mobile .full-width-mobile { @include media-breakpoint-down(xs) { diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index d53a4c1286c..21253e004ef 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -288,7 +288,7 @@ list-style: none; padding: 0 1px; - a, + > a, button, .menu-item { @include dropdown-link; diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 4938215b2e7..8e0314bc6da 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -334,10 +334,6 @@ span.idiff { padding: $gl-padding-8 $gl-padding; margin: 0; border-radius: $border-radius-default $border-radius-default 0 0; - - &.is-stuck { - border-radius: 0; - } } .file-header-content { @@ -490,3 +486,8 @@ span.idiff { overflow-y: auto; max-height: 20rem; } + +#js-openapi-viewer pre.version { + background-color: transparent; + border: transparent; +} diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 2d826064569..1c252584047 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -214,8 +214,8 @@ padding-left: 0; height: $input-height - 2; line-height: inherit; - border-color: transparent; + &, &:focus, &:hover { outline: none; diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 1195e467192..5ae4f72de56 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -552,6 +552,11 @@ svg { vertical-align: text-top; } + + a.trial-link gl-emoji { + font-size: $gl-font-size; + vertical-align: baseline; + } } } diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index ecd32dcd0ce..4aba633e182 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -196,6 +196,11 @@ ul.content-list { display: flex; align-items: center; white-space: nowrap; + + // Override style that allows the flex-row text to wrap. + &.allow-wrap { + white-space: normal; + } } .row-main-content { diff --git a/app/assets/stylesheets/framework/memory_graph.scss b/app/assets/stylesheets/framework/memory_graph.scss index c84010c6f10..06e1ebe41be 100644 --- a/app/assets/stylesheets/framework/memory_graph.scss +++ b/app/assets/stylesheets/framework/memory_graph.scss @@ -1,18 +1,4 @@ .memory-graph-container { - svg { - background: $white-light; - border: 1px solid $gray-200; - } - - path { - fill: none; - stroke: $blue-500; - stroke-width: 2px; - } - - circle { - stroke: $blue-700; - fill: $blue-700; - stroke-width: 4px; - } + background: $white-light; + border: 1px solid $gray-200; } diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index 2289f0a7011..bd0134a82d3 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -118,7 +118,7 @@ background: none; .select2-search-field input { - padding: 5px $gl-padding / 2; + padding: 5px $gl-input-padding; height: auto; font-family: inherit; font-size: inherit; diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index b9cfcf6ce5c..bf1fd7fd29f 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -61,10 +61,6 @@ padding-right: 0; z-index: 300; - .btn-sidebar-action { - display: inline-flex; - } - @include media-breakpoint-only(sm) { &:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper { padding-right: $gutter-collapsed-width; diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 3876d1c10d4..39e7e4bb7e5 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -645,6 +645,12 @@ h4 { } } +.text-right-md { + @include media-breakpoint-up(md) { + text-align: right; + } +} + .text-right-lg { @include media-breakpoint-up(lg) { text-align: right; diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 0f77c451fac..90600ecf615 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -62,6 +62,9 @@ $gray-dark: darken($gray-light, $darken-dark-factor); $gray-darker: #eee; $gray-darkest: #c4c4c4; +$purple: #6d49cb; +$purple-light: #ede8fb; + $black: #000; $black-transparent: rgba(0, 0, 0, 0.3); $almost-black: #242424; @@ -529,16 +532,6 @@ $pagination-line-height: 20px; $pagination-disabled-color: #cdcdcd; /* -* Toasts -*/ -$toast-offset: 24px; -$toast-height: 48px; -$toast-max-width: 586px; -$toast-padding-right: 42px; -$toast-default-margin: 8px; -$toast-background-opacity: 0.95; - -/* * Status icons */ $status-icon-size: 22px; diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss index 434cbd6d21c..3eff1807403 100644 --- a/app/assets/stylesheets/framework/wells.scss +++ b/app/assets/stylesheets/framework/wells.scss @@ -3,7 +3,7 @@ color: $gl-text-color; border: 1px solid $border-color; border-radius: $border-radius-default; - margin-bottom: $gl-padding; + margin-bottom: $gl-padding-8; .card.card-body-segment { padding: $gl-padding; diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index ba126d59eef..977fc8329b6 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -883,6 +883,15 @@ $ide-commit-header-height: 48px; margin-right: $ide-tree-padding; border-bottom: 1px solid $white-dark; + svg { + color: $gray-700; + + &:focus, + &:hover { + color: $blue-600; + } + } + .ide-new-btn { margin-left: auto; } @@ -899,6 +908,11 @@ $ide-commit-header-height: 48px; .dropdown-menu-toggle { svg { vertical-align: middle; + color: $gray-700; + + &:hover { + color: $gray-700; + } } &:hover { diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index d26979bc174..90c2e369ccd 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -187,6 +187,10 @@ font-size: 1em; border-bottom: 1px solid $border-color; padding: $gl-padding-8 $gl-padding; + + .js-max-issue-size::before { + content: '/'; + } } .board-title-text { diff --git a/app/assets/stylesheets/pages/convdev_index.scss b/app/assets/stylesheets/pages/dev_ops_score.scss index 52fcdf4a405..6b6dce43dba 100644 --- a/app/assets/stylesheets/pages/convdev_index.scss +++ b/app/assets/stylesheets/pages/dev_ops_score.scss @@ -1,24 +1,24 @@ $space-between-cards: 8px; -.convdev-empty svg { +.devops-empty svg { margin: 64px auto 32px; max-width: 420px; } -.convdev-header { +.devops-header { margin-top: $gl-padding; margin-bottom: $gl-padding; padding: 0 4px; display: flex; align-items: center; - .convdev-header-title { + .devops-header-title { font-size: 48px; line-height: 1; margin: 0; } - .convdev-header-subtitle { + .devops-header-subtitle { font-size: 22px; line-height: 1; color: $gl-text-color-secondary; @@ -36,13 +36,13 @@ $space-between-cards: 8px; } } -.convdev-cards { +.devops-cards { display: flex; justify-content: center; flex-wrap: wrap; } -.convdev-card-wrapper { +.devops-card-wrapper { display: flex; flex-direction: column; align-items: stretch; @@ -70,7 +70,7 @@ $space-between-cards: 8px; } } -.convdev-card { +.devops-card { border: solid 1px $border-color; border-radius: 3px; border-top-width: 3px; @@ -79,7 +79,7 @@ $space-between-cards: 8px; flex-grow: 1; } -.convdev-card-low { +.devops-card-low { border-top-color: $red-400; .board-card-score-big { @@ -87,7 +87,7 @@ $space-between-cards: 8px; } } -.convdev-card-average { +.devops-card-average { border-top-color: $orange-400; .board-card-score-big { @@ -95,7 +95,7 @@ $space-between-cards: 8px; } } -.convdev-card-high { +.devops-card-high { border-top-color: $green-400; .board-card-score-big { @@ -103,7 +103,7 @@ $space-between-cards: 8px; } } -.convdev-card-title { +.devops-card-title { margin: $gl-padding auto auto; max-width: 100px; @@ -170,7 +170,7 @@ $space-between-cards: 8px; } } -.convdev-steps { +.devops-steps { margin-top: $gl-padding; height: 1px; min-width: 100%; @@ -179,7 +179,7 @@ $space-between-cards: 8px; background: $border-color; } -.convdev-step { +.devops-step { $step-positions: 5% 10% 30% 42% 48% 55% 60% 70% 75% 90%; @each $pos in $step-positions { $i: index($step-positions, $pos); @@ -212,7 +212,7 @@ $space-between-cards: 8px; height: auto; width: auto; - .convdev-step-title { + .devops-step-title { max-height: 2em; opacity: 1; transition: opacity 0.2s; @@ -233,7 +233,7 @@ $space-between-cards: 8px; } } -.convdev-step-title { +.devops-step-title { max-height: 0; opacity: 0; text-transform: uppercase; @@ -242,14 +242,14 @@ $space-between-cards: 8px; font-size: 12px; } -.convdev-high-score { +.devops-high-score { color: $green-400; } -.convdev-average-score { +.devops-average-score { color: $orange-400; } -.convdev-low-score { +.devops-low-score { color: $red-400; } diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index defa1a6c0d5..f394e4ab58a 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -10,6 +10,7 @@ .file-title-flex-parent { border-top-left-radius: $border-radius-default; border-top-right-radius: $border-radius-default; + box-shadow: 0 -2px 0 0 var(--white); cursor: pointer; @media (min-width: map-get($grid-breakpoints, md)) { @@ -472,6 +473,7 @@ table.code { text-align: right; width: 50px; position: relative; + white-space: nowrap; a { transition: none; diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss index 65d0ce8c52e..b716c6e14fe 100644 --- a/app/assets/stylesheets/pages/editor.scss +++ b/app/assets/stylesheets/pages/editor.scss @@ -172,7 +172,7 @@ } .template-selector-dropdowns-wrap { - display: inline-block; + display: flex; vertical-align: top; @media(max-width: map-get($grid-breakpoints, lg)-1) { @@ -189,6 +189,7 @@ display: inline-block; vertical-align: top; font-family: $regular_font; + margin: 0 8px 0 0; @media(max-width: map-get($grid-breakpoints, lg)-1) { display: block; diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 93dffb5ff09..3892d9dbd07 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -7,7 +7,6 @@ .environments-folder-name { font-weight: $gl-font-weight-normal; - padding-top: 20px; } .environments-container { diff --git a/app/assets/stylesheets/pages/error_details.scss b/app/assets/stylesheets/pages/error_details.scss index 0515db914e9..dcd25c126c4 100644 --- a/app/assets/stylesheets/pages/error_details.scss +++ b/app/assets/stylesheets/pages/error_details.scss @@ -12,6 +12,12 @@ } } + .file-title-name { + &.limited-width { + max-width: 80%; + } + } + .line_content.old::before { content: none !important; } diff --git a/app/assets/stylesheets/pages/error_tracking_list.scss b/app/assets/stylesheets/pages/error_tracking_list.scss new file mode 100644 index 00000000000..cd1adb9a754 --- /dev/null +++ b/app/assets/stylesheets/pages/error_tracking_list.scss @@ -0,0 +1,5 @@ +.error-list { + .sort-dropdown { + min-width: auto; + } +} diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index 1502cf18440..1cf72c51ca7 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -25,6 +25,7 @@ .description p { margin-bottom: 0; + color: $gl-text-color-secondary; } } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 5617ab0af41..09b335f9ba2 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -883,7 +883,7 @@ .time-tracking-help-state { background: $white-light; - margin: 16px -20px 0; + margin: 16px -20px -20px; padding: 16px 20px; border-top: 1px solid $border-gray-light; border-bottom: 1px solid $border-gray-light; diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index a37cbda8558..b03ad5c6b75 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -21,16 +21,11 @@ margin-bottom: 2px; } - .issue-labels { + .issue-labels, + .author-link { display: inline-block; } - .issuable-meta { - .author-link { - display: inline-block; - } - } - .icon-merge-request-unmerged { height: 13px; margin-bottom: 3px; @@ -53,16 +48,6 @@ margin-right: 15px; } -.issues_content { - .title { - height: 40px; - } - - form { - margin: 0; - } -} - form.edit-issue { margin: 0; } @@ -79,10 +64,6 @@ ul.related-merge-requests > li { margin-left: 5px; } - .row_title { - vertical-align: bottom; - } - gl-emoji { font-size: 1em; } @@ -93,10 +74,6 @@ ul.related-merge-requests > li { font-weight: $gl-font-weight-bold; } -.merge-request-id { - display: inline-block; -} - .merge-request-status { &.merged { color: $blue-500; @@ -118,11 +95,7 @@ ul.related-merge-requests > li { border-color: $issues-today-border; } - &.closed { - background: $gray-light; - border-color: $border-color; - } - + &.closed, &.merged { background: $gray-light; border-color: $border-color; @@ -160,9 +133,12 @@ ul.related-merge-requests > li { padding-bottom: 37px; } -.issues-nav-controls { +.issues-nav-controls, +.new-branch-col { font-size: 0; +} +.issues-nav-controls { .btn-group:empty { display: none; } @@ -198,8 +174,6 @@ ul.related-merge-requests > li { } .new-branch-col { - font-size: 0; - .discussion-filter-container { &:not(:only-child) { margin-right: $gl-padding-8; @@ -240,7 +214,6 @@ ul.related-merge-requests > li { } .create-merge-request-dropdown-menu { - width: 300px; opacity: 1; visibility: visible; transform: translateY(0); @@ -297,11 +270,11 @@ ul.related-merge-requests > li { padding-top: 0; align-self: center; } + } - .create-mr-dropdown-wrap { - .btn-group:not(.hidden) { - display: inline-flex; - } + .create-mr-dropdown-wrap { + .btn-group:not(.hidden) { + display: inline-flex; } } } diff --git a/app/assets/stylesheets/pages/issues/issue_count_badge.scss b/app/assets/stylesheets/pages/issues/issue_count_badge.scss index 64ca61f7094..569f323abd8 100644 --- a/app/assets/stylesheets/pages/issues/issue_count_badge.scss +++ b/app/assets/stylesheets/pages/issues/issue_count_badge.scss @@ -2,7 +2,6 @@ .mr-count-badge { display: inline-flex; border-radius: $border-radius-base; - border: 1px solid $border-color; padding: 5px $gl-padding-8; } diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 971f3b2c308..c023c9e5cbd 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -51,6 +51,10 @@ position: relative; border: 1px solid $border-color; border-radius: $border-radius-default; + + .gl-skeleton-loader { + display: block; + } } .mr-widget-extension { @@ -949,7 +953,6 @@ .deployment-info { flex: 1; white-space: nowrap; - overflow: hidden; text-overflow: ellipsis; min-width: 100px; diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 87cef43b923..08796742f08 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -17,12 +17,6 @@ display: inline-block; } -.account-btn-link, -.profile-settings-sidebar a, -.settings-sidebar a { - color: $blue-600; -} - .private-tokens-reset div.reset-action:not(:first-child) { padding-top: 15px; } @@ -122,24 +116,12 @@ float: left; } } - - .description { - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - } } .key-created-at { line-height: 42px; } -.profile-settings-content { - a { - color: $blue-600; - } -} - .provider-btn-group { display: inline-block; margin-right: 10px; @@ -169,10 +151,6 @@ margin-left: -3px; line-height: 22px; background-color: $gray-light; - - &.not-active { - color: $blue-500; - } } .oauth-applications { @@ -292,10 +270,6 @@ table.u2f-registrations { } .oauth-application-show { - .scope-name { - font-weight: $gl-font-weight-bold; - } - .scopes-list { padding-left: 18px; } @@ -317,52 +291,52 @@ table.u2f-registrations { .landing { padding: 32px; + } - .close { - position: absolute; - top: 20px; - right: 20px; - opacity: 1; + .close { + position: absolute; + top: 20px; + right: 20px; + opacity: 1; - .dismiss-icon { - float: right; - cursor: pointer; - color: $blue-300; - } + .dismiss-icon { + float: right; + cursor: pointer; + color: $blue-300; + } - &:hover { - background-color: transparent; - border: 0; + &:hover { + background-color: transparent; + border: 0; - .dismiss-icon { - color: $blue-400; - } + .dismiss-icon { + color: $blue-400; } } + } - .svg-container { - margin-right: 30px; - display: inline-block; + .svg-container { + margin-right: 30px; + display: inline-block; - svg { - height: 110px; - vertical-align: top; - } + svg { + height: 110px; + vertical-align: top; + } - &.convdev { - margin: 0 0 0 30px; + &.convdev { + margin: 0 0 0 30px; - svg { - height: 127px; - } + svg { + height: 127px; } } + } - .user-callout-copy { - display: inline-block; - vertical-align: top; - max-width: 570px; - } + .user-callout-copy { + display: inline-block; + vertical-align: top; + max-width: 570px; } @include media-breakpoint-down(xs) { @@ -372,43 +346,26 @@ table.u2f-registrations { display: block; } - .landing { - .svg-container, - .user-callout-copy { - margin: 0 auto; - display: block; + .svg-container, + .user-callout-copy { + margin: 0 auto; + display: block; - svg { - height: 75px; - } + svg { + height: 75px; + } - &.convdev { - margin: $gl-padding auto 0; + &.convdev { + margin: $gl-padding auto 0; - svg { - height: 120px; - } + svg { + height: 120px; } } } } } -.nav-wip { - border: 1px solid $blue-500; - background: $blue-50; - padding: $gl-padding; - margin-bottom: $gl-padding; - - a { - color: $blue-500; - } - - p:last-child { - margin-bottom: 0; - } -} - .email-badge { display: inline; margin-right: $gl-padding / 2; @@ -433,10 +390,8 @@ table.u2f-registrations { } .edit-user { - .clear-user-status { - svg { - fill: $gl-text-color-secondary; - } + svg { + fill: $gl-text-color-secondary; } .form-group > label { @@ -453,10 +408,6 @@ table.u2f-registrations { .no-emoji-placeholder { position: relative; - - svg { - fill: $gl-text-color-secondary; - } } } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 17a446fca53..8b2c67378d9 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -792,7 +792,7 @@ } .btn { - margin-top: $gl-padding; + margin-top: $gl-padding-8; padding: $gl-btn-vert-padding $gl-btn-padding; line-height: $gl-btn-line-height; @@ -812,6 +812,10 @@ @extend .btn; @extend .btn-default; } + + .nav > li:not(:last-child) { + margin-right: $gl-padding-8; + } } .repository-languages-bar { diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss index 154e505f7a4..e20e58e21cf 100644 --- a/app/assets/stylesheets/pages/prometheus.scss +++ b/app/assets/stylesheets/pages/prometheus.scss @@ -67,7 +67,6 @@ .prometheus-graph-group { display: flex; flex-wrap: wrap; - margin-top: $gl-padding-8; } .prometheus-graph { diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index 5664f46484e..79ad0bd7735 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -1,6 +1,6 @@ .tree-holder { .nav-block { - margin: 10px 0; + margin: 16px 0; .btn .fa, .btn svg { @@ -17,6 +17,10 @@ .tree-controls { text-align: right; + .btn { + margin-left: 8px; + } + .btn-group { margin-left: 10px; } diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 3b3a2778b23..1f4bba5fc33 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -29,5 +29,25 @@ .border-color-default { border-color: $border-color; } .box-shadow-default { box-shadow: 0 2px 4px 0 $black-transparent; } +.mh-50vh { max-height: 50vh; } + .gl-w-64 { width: px-to-rem($grid-size * 8); } .gl-h-64 { height: px-to-rem($grid-size * 8); } + +.gl-text-purple { color: $purple; } +.gl-text-gray-800 { color: $gray-800; } +.gl-bg-purple-light { background-color: $purple-light; } + +// Classes using mixins coming from @gitlab-ui +// can be removed once https://gitlab.com/gitlab-org/gitlab/merge_requests/19021 has been merged +.gl-bg-red-100 { @include gl-bg-red-100; } +.gl-bg-orange-100 { @include gl-bg-orange-100; } +.gl-bg-gray-100 { @include gl-bg-gray-100; } +.gl-bg-green-100 { @include gl-bg-green-100;} + +.gl-text-blue-500 { @include gl-text-blue-500; } +.gl-text-gray-900 { @include gl-text-gray-900; } +.gl-text-red-700 { @include gl-text-red-700; } +.gl-text-orange-700 { @include gl-text-orange-700; } +.gl-text-green-700 { @include gl-text-green-700; } + |