diff options
Diffstat (limited to 'app')
909 files changed, 11645 insertions, 5403 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; } + diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb index 6e5dd1a1f55..06ba916fc55 100644 --- a/app/controllers/admin/broadcast_messages_controller.rb +++ b/app/controllers/admin/broadcast_messages_controller.rb @@ -60,6 +60,8 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController font message starts_at + target_path + broadcast_type )) end end diff --git a/app/controllers/admin/identities_controller.rb b/app/controllers/admin/identities_controller.rb index 8f2e34a6294..327538f1e93 100644 --- a/app/controllers/admin/identities_controller.rb +++ b/app/controllers/admin/identities_controller.rb @@ -28,7 +28,8 @@ class Admin::IdentitiesController < Admin::ApplicationController def update if @identity.update(identity_params) - RepairLdapBlockedUserService.new(@user).execute + ::Users::RepairLdapBlockedService.new(@user).execute + redirect_to admin_user_identities_path(@user), notice: _('User identity was successfully updated.') else render :edit @@ -37,7 +38,8 @@ class Admin::IdentitiesController < Admin::ApplicationController def destroy if @identity.destroy - RepairLdapBlockedUserService.new(@user).execute + ::Users::RepairLdapBlockedService.new(@user).execute + redirect_to admin_user_identities_path(@user), status: :found, notice: _('User identity was successfully removed.') else redirect_to admin_user_identities_path(@user), status: :found, alert: _('Failed to remove user identity.') diff --git a/app/controllers/admin/jobs_controller.rb b/app/controllers/admin/jobs_controller.rb index 0c1afdc3d3b..892f6dc657c 100644 --- a/app/controllers/admin/jobs_controller.rb +++ b/app/controllers/admin/jobs_controller.rb @@ -1,25 +1,15 @@ # frozen_string_literal: true class Admin::JobsController < Admin::ApplicationController - # rubocop: disable CodeReuse/ActiveRecord def index + # We need all builds for tabs counters + @all_builds = JobsFinder.new(current_user: current_user).execute + @scope = params[:scope] - @all_builds = Ci::Build - @builds = @all_builds.order('id DESC') - @builds = - case @scope - when 'pending' - @builds.pending.reverse_order - when 'running' - @builds.running.reverse_order - when 'finished' - @builds.finished - else - @builds - end + @builds = JobsFinder.new(current_user: current_user, params: params).execute + @builds = @builds.eager_load_everything @builds = @builds.page(params[:page]).per(30) end - # rubocop: enable CodeReuse/ActiveRecord def cancel_all Ci::Build.running_or_pending.each(&:cancel) diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb index 1f946e41995..f9587655a8d 100644 --- a/app/controllers/admin/sessions_controller.rb +++ b/app/controllers/admin/sessions_controller.rb @@ -6,17 +6,23 @@ class Admin::SessionsController < ApplicationController before_action :user_is_admin! def new - # Renders a form in which the admin can enter their password + if current_user_mode.admin_mode? + redirect_to redirect_path, notice: _('Admin mode already enabled') + else + current_user_mode.request_admin_mode! unless current_user_mode.admin_mode_requested? + store_location_for(:redirect, redirect_path) + end end def create if current_user_mode.enable_admin_mode!(password: params[:password]) - redirect_location = stored_location_for(:redirect) || admin_root_path - redirect_to safe_redirect_path(redirect_location) + redirect_to redirect_path, notice: _('Admin mode enabled') else - flash.now[:alert] = _('Invalid Login or password') + flash.now[:alert] = _('Invalid login or password') render :new end + rescue Gitlab::Auth::CurrentUserMode::NotRequestedError + redirect_to new_admin_session_path, alert: _('Re-authentication period expired or never requested. Please try again') end def destroy @@ -30,4 +36,19 @@ class Admin::SessionsController < ApplicationController def user_is_admin! render_404 unless current_user&.admin? end + + def redirect_path + redirect_to_path = safe_redirect_path(stored_location_for(:redirect)) || safe_redirect_path_for_url(request.referer) + + if redirect_to_path && + excluded_redirect_paths.none? { |excluded| redirect_to_path.include?(excluded) } + redirect_to_path + else + admin_root_path + end + end + + def excluded_redirect_paths + [new_admin_session_path, admin_session_path] + end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 25c1d80b117..f5306801c04 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -16,15 +16,16 @@ class ApplicationController < ActionController::Base include ConfirmEmailWarning include Gitlab::Tracking::ControllerConcern include Gitlab::Experimentation::ControllerConcern + include InitializesCurrentUserMode before_action :authenticate_user!, except: [:route_not_found] before_action :enforce_terms!, if: :should_enforce_terms? before_action :validate_user_service_ticket! - before_action :check_password_expiration + before_action :check_password_expiration, if: :html_request? before_action :ldap_security_check - before_action :sentry_context + around_action :sentry_context before_action :default_headers - before_action :add_gon_variables, unless: [:peek_request?, :json_request?] + before_action :add_gon_variables, if: :html_request? before_action :configure_permitted_parameters, if: :devise_controller? before_action :require_email, unless: :devise_controller? before_action :active_user_check, unless: :devise_controller? @@ -41,7 +42,6 @@ class ApplicationController < ActionController::Base protect_from_forgery with: :exception, prepend: true helper_method :can? - helper_method :current_user_mode helper_method :import_sources_enabled?, :github_import_enabled?, :gitea_import_enabled?, :github_import_configured?, :gitlab_import_enabled?, :gitlab_import_configured?, @@ -74,6 +74,18 @@ class ApplicationController < ActionController::Base render_403 end + rescue_from Gitlab::Auth::IpBlacklisted do + Gitlab::AuthLogger.error( + message: 'Rack_Attack', + env: :blocklist, + remote_ip: request.ip, + request_method: request.request_method, + path: request.fullpath + ) + + head :forbidden + end + rescue_from Gitlab::Auth::TooManyIps do |e| head :forbidden, retry_after: Gitlab::Auth::UniqueIpsLimiter.config.unique_ips_limit_time_window end @@ -153,7 +165,7 @@ class ApplicationController < ActionController::Base end def log_exception(exception) - Gitlab::Sentry.track_acceptable_exception(exception) + Gitlab::ErrorTracking.track_exception(exception) backtrace_cleaner = request.env["action_dispatch.backtrace_cleaner"] application_trace = ActionDispatch::ExceptionWrapper.new(backtrace_cleaner, exception).application_trace @@ -216,10 +228,6 @@ class ApplicationController < ActionController::Base end end - def respond_201 - head :created - end - def respond_422 head :unprocessable_entity end @@ -455,8 +463,8 @@ class ApplicationController < ActionController::Base response.headers['Page-Title'] = URI.escape(page_title('GitLab')) end - def peek_request? - request.path.start_with?('/-/peek') + def html_request? + request.format.html? end def json_request? @@ -466,7 +474,7 @@ class ApplicationController < ActionController::Base def should_enforce_terms? return false unless Gitlab::CurrentSettings.current_application_settings.enforce_terms - !(peek_request? || devise_controller?) + html_request? && !devise_controller? end def set_usage_stats_consent_flag @@ -524,8 +532,8 @@ class ApplicationController < ActionController::Base @impersonator ||= User.find(session[:impersonator_id]) if session[:impersonator_id] end - def sentry_context - Gitlab::Sentry.context(current_user) + def sentry_context(&block) + Gitlab::ErrorTracking.with_context(current_user, &block) end def allow_gitaly_ref_name_caching @@ -534,10 +542,6 @@ class ApplicationController < ActionController::Base end end - def current_user_mode - @current_user_mode ||= Gitlab::Auth::CurrentUserMode.new(current_user) - end - # A user requires a role and have the setup_for_company attribute set when they are part of the experimental signup # flow (executed by the Growth team). Users are redirected to the welcome page when their role is required and the # experiment is enabled for the current user. diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index 06531932b31..0df201ab506 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -40,10 +40,20 @@ class AutocompleteController < ApplicationController end def merge_request_target_branches - merge_requests = MergeRequestsFinder.new(current_user, params).execute - target_branches = merge_requests.recent_target_branches + if target_branch_params.present? + merge_requests = MergeRequestsFinder.new(current_user, target_branch_params).execute + target_branches = merge_requests.recent_target_branches + + render json: target_branches.map { |target_branch| { title: target_branch } } + else + render json: { error: _('At least one of group_id or project_id must be specified') }, status: :bad_request + end + end + + private - render json: target_branches.map { |target_branch| { title: target_branch } } + def target_branch_params + params.permit(:group_id, :project_id).select { |_, v| v.present? } end end diff --git a/app/controllers/boards/lists_controller.rb b/app/controllers/boards/lists_controller.rb index 880f7500708..0b8469e8290 100644 --- a/app/controllers/boards/lists_controller.rb +++ b/app/controllers/boards/lists_controller.rb @@ -53,7 +53,7 @@ module Boards service = Boards::Lists::GenerateService.new(board_parent, current_user) if service.execute(board) - lists = board.lists.movable.preload_associations + lists = board.lists.movable.preload_associated_models List.preload_preferences_for_user(lists, current_user) diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index 9a539cf7c24..f4b74b14c0b 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -3,18 +3,15 @@ class Clusters::ClustersController < Clusters::BaseController include RoutableActions - before_action :cluster, only: [:cluster_status, :show, :update, :destroy] + before_action :cluster, only: [:cluster_status, :show, :update, :destroy, :clear_cache] before_action :generate_gcp_authorize_url, only: [:new] before_action :validate_gcp_token, only: [:new] before_action :gcp_cluster, only: [:new] before_action :user_cluster, only: [:new] - before_action :authorize_create_cluster!, only: [:new, :authorize_aws_role, :revoke_aws_role, :aws_proxy] + before_action :authorize_create_cluster!, only: [:new, :authorize_aws_role] before_action :authorize_update_cluster!, only: [:update] - before_action :authorize_admin_cluster!, only: [:destroy] + before_action :authorize_admin_cluster!, only: [:destroy, :clear_cache] before_action :update_applications_status, only: [:cluster_status] - before_action only: [:new, :create_gcp] do - push_frontend_feature_flag(:create_eks_clusters) - end before_action only: [:show] do push_frontend_feature_flag(:enable_cluster_application_elastic_stack) push_frontend_feature_flag(:enable_cluster_application_crossplane) @@ -42,11 +39,10 @@ class Clusters::ClustersController < Clusters::BaseController end def new - return unless Feature.enabled?(:create_eks_clusters) - if params[:provider] == 'aws' @aws_role = current_user.aws_role || Aws::Role.new @aws_role.ensure_role_external_id! + @instance_types = load_instance_types.to_json elsif params[:provider] == 'gcp' redirect_to @authorize_url if @authorize_url && !@valid_gcp_token @@ -113,6 +109,7 @@ class Clusters::ClustersController < Clusters::BaseController generate_gcp_authorize_url validate_gcp_token user_cluster + params[:provider] = 'gcp' render :new, locals: { active_tab: 'create' } end @@ -149,34 +146,24 @@ class Clusters::ClustersController < Clusters::BaseController end def authorize_aws_role - role = current_user.build_aws_role(create_role_params) - - role.save ? respond_201 : respond_422 - end - - def revoke_aws_role - current_user.aws_role&.destroy + response = Clusters::Aws::AuthorizeRoleService.new( + current_user, + params: aws_role_params + ).execute - head :no_content + render json: response.body, status: response.status end - def aws_proxy - response = Clusters::Aws::ProxyService.new( - current_user.aws_role, - params: params - ).execute + def clear_cache + cluster.delete_cached_resources! - render json: response.body, status: response.status + redirect_to cluster.show_path, notice: _('Cluster cache cleared.') end private def destroy_params - # To be uncomented on https://gitlab.com/gitlab-org/gitlab/merge_requests/16954 - # This MR got split into other since it was too big. - # - # params.permit(:cleanup) - {} + params.permit(:cleanup) end def update_params @@ -270,13 +257,12 @@ class Clusters::ClustersController < Clusters::BaseController ) end - def create_role_params + def aws_role_params params.require(:cluster).permit(:role_arn, :role_external_id) end def generate_gcp_authorize_url - params = Feature.enabled?(:create_eks_clusters) ? { provider: :gke } : {} - state = generate_session_key_redirect(clusterable.new_path(params).to_s) + state = generate_session_key_redirect(clusterable.new_path(provider: :gcp).to_s) @authorize_url = GoogleApi::CloudPlatform::Client.new( nil, callback_google_api_auth_url, @@ -317,6 +303,19 @@ class Clusters::ClustersController < Clusters::BaseController end end + ## + # Unfortunately the EC2 API doesn't provide a list of + # possible instance types. There is a workaround, using + # the Pricing API, but instead of requiring the + # user to grant extra permissions for this we use the + # values that validate the CloudFormation template. + def load_instance_types + stack_template = File.read(Rails.root.join('vendor', 'aws', 'cloudformation', 'eks_cluster.yaml')) + instance_types = YAML.safe_load(stack_template).dig('Parameters', 'NodeInstanceType', 'AllowedValues') + + instance_types.map { |type| Hash(name: type, value: type) } + end + def update_applications_status @cluster.applications.each(&:schedule_status_update) end diff --git a/app/controllers/concerns/boards_actions.rb b/app/controllers/concerns/boards_actions.rb index a093d0d6e7f..eb1080cb3d2 100644 --- a/app/controllers/concerns/boards_actions.rb +++ b/app/controllers/concerns/boards_actions.rb @@ -9,6 +9,7 @@ module BoardsActions before_action :boards, only: :index before_action :board, only: :show + before_action :push_wip_limits, only: [:index, :show] end def index @@ -24,6 +25,10 @@ module BoardsActions private + # Noop on FOSS + def push_wip_limits + end + def boards strong_memoize(:boards) do Boards::ListService.new(parent, current_user).execute diff --git a/app/controllers/concerns/confirm_email_warning.rb b/app/controllers/concerns/confirm_email_warning.rb index 86df0010665..32e1a46e580 100644 --- a/app/controllers/concerns/confirm_email_warning.rb +++ b/app/controllers/concerns/confirm_email_warning.rb @@ -4,15 +4,18 @@ module ConfirmEmailWarning extend ActiveSupport::Concern included do - before_action :set_confirm_warning, if: -> { Feature.enabled?(:soft_email_confirmation) } + before_action :set_confirm_warning, if: :show_confirm_warning? end protected + def show_confirm_warning? + html_request? && request.get? && Feature.enabled?(:soft_email_confirmation) + end + def set_confirm_warning return unless current_user return if current_user.confirmed? - return if peek_request? || json_request? || !request.get? email = current_user.unconfirmed_email || current_user.email diff --git a/app/controllers/concerns/cycle_analytics_params.rb b/app/controllers/concerns/cycle_analytics_params.rb index 1645af695be..a78d803927c 100644 --- a/app/controllers/concerns/cycle_analytics_params.rb +++ b/app/controllers/concerns/cycle_analytics_params.rb @@ -38,7 +38,8 @@ module CycleAnalyticsParams end def to_utc_time(field) - Date.parse(field).to_time.utc + date = field.is_a?(Date) ? field : Date.parse(field) + date.to_time.utc end end diff --git a/app/controllers/concerns/enforces_admin_authentication.rb b/app/controllers/concerns/enforces_admin_authentication.rb index e731211f423..527759de0bb 100644 --- a/app/controllers/concerns/enforces_admin_authentication.rb +++ b/app/controllers/concerns/enforces_admin_authentication.rb @@ -18,6 +18,7 @@ module EnforcesAdminAuthentication return unless Feature.enabled?(:user_mode_in_session) unless current_user_mode.admin_mode? + current_user_mode.request_admin_mode! store_location_for(:redirect, request.fullpath) if storable_location? redirect_to(new_admin_session_path, notice: _('Re-authentication required')) end diff --git a/app/controllers/concerns/initializes_current_user_mode.rb b/app/controllers/concerns/initializes_current_user_mode.rb new file mode 100644 index 00000000000..df7cea5c754 --- /dev/null +++ b/app/controllers/concerns/initializes_current_user_mode.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module InitializesCurrentUserMode + extend ActiveSupport::Concern + + included do + helper_method :current_user_mode + end + + def current_user_mode + @current_user_mode ||= Gitlab::Auth::CurrentUserMode.new(current_user) + end +end diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 6162d006cc7..c4abaacd573 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -98,13 +98,11 @@ module IssuableActions error_message = "Destroy confirmation not provided for #{issuable.human_class_name}" exception = RuntimeError.new(error_message) - Gitlab::Sentry.track_acceptable_exception( + Gitlab::ErrorTracking.track_exception( exception, - extra: { - project_path: issuable.project.full_path, - issuable_type: issuable.class.name, - issuable_id: issuable.id - } + project_path: issuable.project.full_path, + issuable_type: issuable.class.name, + issuable_id: issuable.id ) index_path = polymorphic_path([parent, issuable.class]) @@ -121,7 +119,7 @@ module IssuableActions end def bulk_update - result = Issuable::BulkUpdateService.new(current_user, bulk_update_params).execute(resource_name) + result = Issuable::BulkUpdateService.new(parent, current_user, bulk_update_params).execute(resource_name) quantity = result[:count] render json: { notice: "#{quantity} #{resource_name.pluralize(quantity)} updated" } diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index 0b2756c0c6a..993f091b0e6 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -144,4 +144,15 @@ module MembershipActions end end end + + def requested_relations + case params[:with_inherited_permissions].presence + when 'exclude' + [:direct] + when 'only' + [:inherited] + else + [:inherited, :direct] + end + end end diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index fbae4c53c31..3d599d9e7f9 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -63,7 +63,11 @@ module NotesActions json.merge!(note_json(@note)) end - render json: json + if @note.errors.present? && @note.errors.keys != [:commands_only] + render json: json, status: :unprocessable_entity + else + render json: json + end end format.html { redirect_back_or_default } end diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb index fd9d5fad38e..3ccf227c431 100644 --- a/app/controllers/concerns/service_params.rb +++ b/app/controllers/concerns/service_params.rb @@ -18,6 +18,7 @@ module ServiceParams :channels, :color, :colorize_messages, + :comment_on_event_enabled, :confidential_issues_events, :default_irc_uri, :description, diff --git a/app/controllers/concerns/sessionless_authentication.rb b/app/controllers/concerns/sessionless_authentication.rb index f644923443b..d5c26fca957 100644 --- a/app/controllers/concerns/sessionless_authentication.rb +++ b/app/controllers/concerns/sessionless_authentication.rb @@ -33,6 +33,8 @@ module SessionlessAuthentication end def enable_admin_mode! - current_user_mode.enable_admin_mode!(skip_password_validation: true) if Feature.enabled?(:user_mode_in_session) + return unless Feature.enabled?(:user_mode_in_session) + + current_user_mode.enable_sessionless_admin_mode! end end diff --git a/app/controllers/concerns/sourcegraph_gon.rb b/app/controllers/concerns/sourcegraph_gon.rb index ab4abd734fb..01925cf9d4d 100644 --- a/app/controllers/concerns/sourcegraph_gon.rb +++ b/app/controllers/concerns/sourcegraph_gon.rb @@ -4,7 +4,7 @@ module SourcegraphGon extend ActiveSupport::Concern included do - before_action :push_sourcegraph_gon, unless: :json_request? + before_action :push_sourcegraph_gon, if: :html_request? end private diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb index b87779c22d3..655575e0944 100644 --- a/app/controllers/concerns/uploads_actions.rb +++ b/app/controllers/concerns/uploads_actions.rb @@ -1,11 +1,16 @@ # frozen_string_literal: true module UploadsActions + extend ActiveSupport::Concern include Gitlab::Utils::StrongMemoize include SendFileUpload UPLOAD_MOUNTS = %w(avatar attachment file logo header_logo favicon).freeze + included do + prepend_before_action :set_request_format_from_path_extension + end + def create uploader = UploadService.new(model, params[:file], uploader_class).execute @@ -39,15 +44,14 @@ module UploadsActions expires_in ttl, directives - disposition = uploader.embeddable? ? 'inline' : 'attachment' - - uploaders = [uploader, *uploader.versions.values] - uploader = uploaders.find { |version| version.filename == params[:filename] } + file_uploader = [uploader, *uploader.versions.values].find do |version| + version.filename == params[:filename] + end - return render_404 unless uploader + return render_404 unless file_uploader workhorse_set_content_type! - send_upload(uploader, attachment: uploader.filename, disposition: disposition) + send_upload(file_uploader, attachment: file_uploader.filename, disposition: content_disposition) end def authorize @@ -64,6 +68,28 @@ module UploadsActions private + # Based on ActionDispatch::Http::MimeNegotiation. We have an + # initializer that monkey-patches this method out (so that repository + # paths don't guess a format based on extension), but we do want this + # behavior when serving uploads. + def set_request_format_from_path_extension + path = request.headers['action_dispatch.original_path'] || request.headers['PATH_INFO'] + + if match = path&.match(/\.(\w+)\z/) + format = Mime[match.captures.first] + + request.format = format.symbol if format + end + end + + def content_disposition + if uploader.embeddable? || uploader.pdf? + 'inline' + else + 'attachment' + end + end + def uploader_class raise NotImplementedError end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 1b1416a72d7..dcdf9aced1a 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -24,8 +24,7 @@ class Groups::GroupMembersController < Groups::ApplicationController @sort = params[:sort].presence || sort_value_name @project = @group.projects.find(params[:project_id]) if params[:project_id] - - @members = GroupMembersFinder.new(@group).execute + @members = find_members if can_manage_members @invited_members = @members.invite @@ -52,6 +51,12 @@ class Groups::GroupMembersController < Groups::ApplicationController # MembershipActions concern alias_method :membershipable, :group + + private + + def find_members + GroupMembersFinder.new(@group).execute(include_relations: requested_relations) + end end Groups::GroupMembersController.prepend_if_ee('EE::Groups::GroupMembersController') diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 755d97b091c..0953ca96317 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -181,6 +181,7 @@ class GroupsController < Groups::ApplicationController :avatar, :description, :emails_disabled, + :mentions_disabled, :lfs_enabled, :name, :path, diff --git a/app/controllers/instance_statistics/conversational_development_index_controller.rb b/app/controllers/instance_statistics/conversational_development_index_controller.rb deleted file mode 100644 index 306c16d559c..00000000000 --- a/app/controllers/instance_statistics/conversational_development_index_controller.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class InstanceStatistics::ConversationalDevelopmentIndexController < InstanceStatistics::ApplicationController - # rubocop: disable CodeReuse/ActiveRecord - def index - @metric = ConversationalDevelopmentIndex::Metric.order(:created_at).last&.present - end - # rubocop: enable CodeReuse/ActiveRecord -end diff --git a/app/controllers/instance_statistics/dev_ops_score_controller.rb b/app/controllers/instance_statistics/dev_ops_score_controller.rb new file mode 100644 index 00000000000..238f7fa7707 --- /dev/null +++ b/app/controllers/instance_statistics/dev_ops_score_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class InstanceStatistics::DevOpsScoreController < InstanceStatistics::ApplicationController + # rubocop: disable CodeReuse/ActiveRecord + def index + @metric = DevOpsScore::Metric.order(:created_at).last&.present + end + # rubocop: enable CodeReuse/ActiveRecord +end diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index 8dd51ce1d64..bbf0bdd3662 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -6,6 +6,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController include PageLayoutHelper include OauthApplications include Gitlab::Experimentation::ControllerConcern + include InitializesCurrentUserMode before_action :verify_user_oauth_applications_enabled, except: :index before_action :authenticate_user! diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index e65726dffbf..2a4e659c5b9 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -2,6 +2,8 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController include Gitlab::Experimentation::ControllerConcern + include InitializesCurrentUserMode + layout 'profile' # Overridden from Doorkeeper::AuthorizationsController to diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index eca58748cc5..92f36c031f1 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -4,6 +4,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController include AuthenticatesWithTwoFactor include Devise::Controllers::Rememberable include AuthHelper + include InitializesCurrentUserMode protect_from_forgery except: [:kerberos, :saml, :cas3, :failure], with: :exception, prepend: true @@ -94,8 +95,12 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController return render_403 unless link_provider_allowed?(oauth['provider']) log_audit_event(current_user, with: oauth['provider']) - identity_linker ||= auth_module::IdentityLinker.new(current_user, oauth, session) + if Feature.enabled?(:user_mode_in_session) + return admin_mode_flow if current_user_mode.admin_mode_requested? + end + + identity_linker ||= auth_module::IdentityLinker.new(current_user, oauth, session) link_identity(identity_linker) if identity_linker.changed? @@ -239,6 +244,24 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController store_location_for(:user, uri.to_s) end end + + def admin_mode_flow + if omniauth_identity_matches_current_user? + current_user_mode.enable_admin_mode!(skip_password_validation: true) + + redirect_to stored_location_for(:redirect) || admin_root_path, notice: _('Admin mode enabled') + else + fail_admin_mode_invalid_credentials + end + end + + def omniauth_identity_matches_current_user? + current_user.matches_identity?(oauth['provider'], oauth['uid']) + end + + def fail_admin_mode_invalid_credentials + redirect_to new_admin_session_path, alert: _('Invalid login or password') + end end OmniauthCallbacksController.prepend_if_ee('EE::OmniauthCallbacksController') diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 578a3d451a7..09754409104 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -46,7 +46,7 @@ class Projects::BranchesController < Projects::ApplicationController def diverging_commit_counts respond_to do |format| format.json do - service = Branches::DivergingCommitCountsService.new(repository) + service = ::Branches::DivergingCommitCountsService.new(repository) branches = BranchesFinder.new(repository, params.permit(names: [])).execute Gitlab::GitalyClient.allow_n_plus_1_calls do @@ -63,7 +63,7 @@ class Projects::BranchesController < Projects::ApplicationController redirect_to_autodeploy = project.empty_repo? && project.deployment_platform.present? - result = CreateBranchService.new(project, current_user) + result = ::Branches::CreateService.new(project, current_user) .execute(branch_name, ref) success = (result[:status] == :success) @@ -102,7 +102,7 @@ class Projects::BranchesController < Projects::ApplicationController def destroy @branch_name = Addressable::URI.unescape(params[:id]) - result = DeleteBranchService.new(project, current_user).execute(@branch_name) + result = ::Branches::DeleteService.new(project, current_user).execute(@branch_name) respond_to do |format| format.html do @@ -118,7 +118,7 @@ class Projects::BranchesController < Projects::ApplicationController end def destroy_all_merged - DeleteMergedBranchesService.new(@project, current_user).async_execute + ::Branches::DeleteMergedService.new(@project, current_user).async_execute redirect_to project_branches_path(@project), notice: _('Merged branches are being deleted. This can take some time depending on the number of branches. Please refresh the page to see changes.') @@ -133,8 +133,6 @@ class Projects::BranchesController < Projects::ApplicationController # frontend could omit this set. To prevent excessive I/O, we require # that a list of names be specified. def limit_diverging_commit_counts! - return unless Feature.enabled?(:limit_diverging_commit_counts, default_enabled: true) - limit = Kaminari.config.default_per_page # If we don't have many branches in the repository, then go ahead. diff --git a/app/controllers/projects/ci/lints_controller.rb b/app/controllers/projects/ci/lints_controller.rb index d7a0b7ece14..812420e9708 100644 --- a/app/controllers/projects/ci/lints_controller.rb +++ b/app/controllers/projects/ci/lints_controller.rb @@ -8,11 +8,13 @@ class Projects::Ci::LintsController < Projects::ApplicationController def create @content = params[:content] - @error = Gitlab::Ci::YamlProcessor.validation_message(@content, yaml_processor_options) - @status = @error.blank? + result = Gitlab::Ci::YamlProcessor.new_with_validation_errors(@content, yaml_processor_options) - if @error.blank? - @config_processor = Gitlab::Ci::YamlProcessor.new(@content, yaml_processor_options) + @error = result.errors.join(', ') + @status = result.valid? + + if result.valid? + @config_processor = result.content @stages = @config_processor.stages @builds = @config_processor.builds @jobs = @config_processor.jobs diff --git a/app/controllers/projects/environments/prometheus_api_controller.rb b/app/controllers/projects/environments/prometheus_api_controller.rb index e902d218c75..98fcc594d6e 100644 --- a/app/controllers/projects/environments/prometheus_api_controller.rb +++ b/app/controllers/projects/environments/prometheus_api_controller.rb @@ -7,23 +7,34 @@ class Projects::Environments::PrometheusApiController < Projects::ApplicationCon before_action :environment def proxy - result = Prometheus::ProxyService.new( + variable_substitution_result = + variable_substitution_service.new(environment, permit_params).execute + + if variable_substitution_result[:status] == :error + return error_response(variable_substitution_result) + end + + prometheus_result = Prometheus::ProxyService.new( environment, proxy_method, proxy_path, - proxy_params + variable_substitution_result[:params] ).execute - return continue_polling_response if result.nil? - return error_response(result) if result[:status] == :error + return continue_polling_response if prometheus_result.nil? + return error_response(prometheus_result) if prometheus_result[:status] == :error - success_response(result) + success_response(prometheus_result) end private - def query_context - Gitlab::Prometheus::QueryVariables.call(environment) + def variable_substitution_service + Prometheus::ProxyVariableSubstitutionService + end + + def permit_params + params.permit! end def environment @@ -37,15 +48,4 @@ class Projects::Environments::PrometheusApiController < Projects::ApplicationCon def proxy_path params[:proxy_path] end - - def proxy_params - substitute_query_variables(params).permit! - end - - def substitute_query_variables(params) - query = params[:query] - return params unless query - - params.merge(query: query % query_context) - end end diff --git a/app/controllers/projects/environments/sample_metrics_controller.rb b/app/controllers/projects/environments/sample_metrics_controller.rb new file mode 100644 index 00000000000..79a7eab150b --- /dev/null +++ b/app/controllers/projects/environments/sample_metrics_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Projects::Environments::SampleMetricsController < Projects::ApplicationController + def query + result = Metrics::SampleMetricsService.new(params[:identifier]).query + + if result + render json: { "status": "success", "data": { "resultType": "matrix", "result": result } } + else + render_404 + end + end +end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 4562296cea0..1179782036d 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -7,14 +7,15 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action :authorize_read_environment! before_action :authorize_create_environment!, only: [:new, :create] before_action :authorize_stop_environment!, only: [:stop] - before_action :authorize_update_environment!, only: [:edit, :update] + before_action :authorize_update_environment!, only: [:edit, :update, :cancel_auto_stop] before_action :authorize_admin_environment!, only: [:terminal, :terminal_websocket_authorize] - before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics] + before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics, :cancel_auto_stop] before_action :verify_api_request!, only: :terminal_websocket_authorize - before_action :expire_etag_cache, only: [:index] + before_action :expire_etag_cache, only: [:index], unless: -> { request.format.json? } before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do push_frontend_feature_flag(:prometheus_computed_alerts) end + after_action :expire_etag_cache, only: [:cancel_auto_stop] def index @environments = project.environments @@ -104,6 +105,27 @@ class Projects::EnvironmentsController < Projects::ApplicationController end end + def cancel_auto_stop + result = Environments::ResetAutoStopService.new(project, current_user) + .execute(environment) + + if result[:status] == :success + respond_to do |format| + message = _('Auto stop successfully canceled.') + + format.html { redirect_back_or_default(default: { action: 'show' }, options: { notice: message }) } + format.json { render json: { message: message }, status: :ok } + end + else + respond_to do |format| + message = result[:message] + + format.html { redirect_back_or_default(default: { action: 'show' }, options: { alert: message }) } + format.json { render json: { message: message }, status: :unprocessable_entity } + end + end + end + def terminal # Currently, this acts as a hint to load the terminal details into the cache # if they aren't there already. In the future, users will need these details @@ -175,8 +197,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController end def expire_etag_cache - return if request.format.json? - # this forces to reload json content Gitlab::EtagCaching::Store.new.tap do |store| store.touch(project_environments_path(project, format: :json)) @@ -222,6 +242,10 @@ class Projects::EnvironmentsController < Projects::ApplicationController def authorize_stop_environment! access_denied! unless can?(current_user, :stop_environment, environment) end + + def authorize_update_environment! + access_denied! unless can?(current_user, :update_environment, environment) + end end Projects::EnvironmentsController.prepend_if_ee('EE::Projects::EnvironmentsController') diff --git a/app/controllers/projects/error_tracking_controller.rb b/app/controllers/projects/error_tracking_controller.rb index 7143424473e..ba21ccfb169 100644 --- a/app/controllers/projects/error_tracking_controller.rb +++ b/app/controllers/projects/error_tracking_controller.rb @@ -44,13 +44,18 @@ class Projects::ErrorTrackingController < Projects::ApplicationController private def render_index_json - service = ErrorTracking::ListIssuesService.new(project, current_user) + service = ErrorTracking::ListIssuesService.new( + project, + current_user, + list_issues_params + ) result = service.execute return if handle_errors(result) render json: { errors: serialize_errors(result[:issues]), + pagination: result[:pagination], external_url: service.external_url } end @@ -72,8 +77,10 @@ class Projects::ErrorTrackingController < Projects::ApplicationController return if handle_errors(result) + result_with_syntax_highlight = Gitlab::ErrorTracking::StackTraceHighlightDecorator.decorate(result[:latest_event]) + render json: { - error: serialize_error_event(result[:latest_event]) + error: serialize_error_event(result_with_syntax_highlight) } end @@ -106,6 +113,10 @@ class Projects::ErrorTrackingController < Projects::ApplicationController end end + def list_issues_params + params.permit(:search_term, :sort, :cursor) + end + def list_projects_params params.require(:error_tracking_setting).permit([:api_host, :token]) end diff --git a/app/controllers/projects/hook_logs_controller.rb b/app/controllers/projects/hook_logs_controller.rb index a7afc3d77a5..ed7e7b68acb 100644 --- a/app/controllers/projects/hook_logs_controller.rb +++ b/app/controllers/projects/hook_logs_controller.rb @@ -16,15 +16,17 @@ class Projects::HookLogsController < Projects::ApplicationController end def retry - result = hook.execute(hook_log.request_data, hook_log.trigger) - - set_hook_execution_notice(result) - + execute_hook redirect_to edit_project_hook_path(@project, @hook) end private + def execute_hook + result = hook.execute(hook_log.request_data, hook_log.trigger) + set_hook_execution_notice(result) + end + def hook @hook ||= @project.hooks.find(params[:hook_id]) end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 009765702ab..229374c3929 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -44,7 +44,7 @@ class Projects::IssuesController < Projects::ApplicationController before_action do push_frontend_feature_flag(:vue_issuable_sidebar, project.group) - push_frontend_feature_flag(:release_search_filter, project) + push_frontend_feature_flag(:release_search_filter, project, default_enabled: true) end respond_to :html @@ -237,7 +237,10 @@ class Projects::IssuesController < Projects::ApplicationController end def issue_params - params.require(:issue).permit(*issue_params_attributes) + params.require(:issue).permit( + *issue_params_attributes, + sentry_issue_attributes: [:sentry_issue_identifier] + ) end def issue_params_attributes diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 1d914ab6011..796f3ff603f 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -12,39 +12,20 @@ class Projects::JobsController < Projects::ApplicationController before_action :authorize_use_build_terminal!, only: [:terminal, :terminal_websocket_authorize] before_action :verify_api_request!, only: :terminal_websocket_authorize before_action only: [:show] do - push_frontend_feature_flag(:job_log_json, project) + push_frontend_feature_flag(:job_log_json, project, default_enabled: true) end layout 'project' - # rubocop: disable CodeReuse/ActiveRecord def index + # We need all builds for tabs counters + @all_builds = JobsFinder.new(current_user: current_user, project: @project).execute + @scope = params[:scope] - @all_builds = project.builds.relevant - @builds = @all_builds.order('ci_builds.id DESC') - @builds = - case @scope - when 'pending' - @builds.pending.reverse_order - when 'running' - @builds.running.reverse_order - when 'finished' - @builds.finished - else - @builds - end - @builds = @builds.includes([ - { pipeline: [:project, :user] }, - :job_artifacts_archive, - :metadata, - :trigger_request, - :project, - :user, - :tags - ]) + @builds = JobsFinder.new(current_user: current_user, project: @project, params: params).execute + @builds = @builds.eager_load_everything @builds = @builds.page(params[:page]).per(30).without_count end - # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord def show @@ -72,7 +53,7 @@ class Projects::JobsController < Projects::ApplicationController format.json do # TODO: when the feature flag is removed we should not pass # content_format to serialize method. - content_format = Feature.enabled?(:job_log_json, @project) ? :json : :html + content_format = Feature.enabled?(:job_log_json, @project, default_enabled: true) ? :json : :html build_trace = Ci::BuildTrace.new( build: @build, diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 42f9c0522a3..37d90ecdc00 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -5,8 +5,8 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic include RendersNotes before_action :apply_diff_view_cookie! - before_action :commit, except: :diffs_batch - before_action :define_diff_vars, except: :diffs_batch + before_action :commit + before_action :define_diff_vars before_action :define_diff_comment_vars, except: [:diffs_batch, :diffs_metadata] def show @@ -20,14 +20,11 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic def diffs_batch return render_404 unless Feature.enabled?(:diffs_batch_load, @merge_request.project) - diffable = @merge_request.merge_request_diff - - return render_404 unless diffable - - diffs = diffable.diffs_in_batch(params[:page], params[:per_page], diff_options: diff_options) + diffs = @compare.diffs_in_batch(params[:page], params[:per_page], diff_options: diff_options) positions = @merge_request.note_positions_for_paths(diffs.diff_file_paths, current_user) diffs.unfold_diff_files(positions.unfoldable) + diffs.write_cache options = { merge_request: @merge_request, @@ -39,8 +36,10 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic end def diffs_metadata + diffs = @compare.diffs(diff_options) + render json: DiffsMetadataSerializer.new(project: @merge_request.project) - .represent(@diffs, additional_attributes) + .represent(diffs, additional_attributes) end private @@ -49,11 +48,13 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic [{ source_project: :namespace }, { target_project: :namespace }] end + # Deprecated: https://gitlab.com/gitlab-org/gitlab/issues/37735 def render_diffs + diffs = @compare.diffs(diff_options) @environment = @merge_request.environments_for(current_user).last - @diffs.unfold_diff_files(note_positions.unfoldable) - @diffs.write_cache + diffs.unfold_diff_files(note_positions.unfoldable) + diffs.write_cache request = { current_user: current_user, @@ -63,15 +64,14 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic options = additional_attributes.merge(diff_view: diff_view) - render json: DiffsSerializer.new(request).represent(@diffs, options) + render json: DiffsSerializer.new(request).represent(diffs, options) end + # Deprecated: https://gitlab.com/gitlab-org/gitlab/issues/37735 def define_diff_vars @merge_request_diffs = @merge_request.merge_request_diffs.viewable.order_id_desc @compare = commit || find_merge_request_diff_compare return render_404 unless @compare - - @diffs = @compare.diffs(diff_options) end # rubocop: disable CodeReuse/ActiveRecord @@ -84,6 +84,8 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord + # + # Deprecated: https://gitlab.com/gitlab-org/gitlab/issues/37735 def find_merge_request_diff_compare @merge_request_diff = if diff_id = params[:diff_id].presence @@ -126,6 +128,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic } end + # Deprecated: https://gitlab.com/gitlab-org/gitlab/issues/37735 def define_diff_comment_vars @new_diff_note_attrs = { noteable_type: 'MergeRequest', diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 766ec1e33f3..69e3e7c7acb 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -20,11 +20,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action :check_user_can_push_to_source_branch!, only: [:rebase] before_action only: [:show] do push_frontend_feature_flag(:diffs_batch_load, @project) + push_frontend_feature_flag(:single_mr_diff_view, @project) end before_action do push_frontend_feature_flag(:vue_issuable_sidebar, @project.group) - push_frontend_feature_flag(:release_search_filter, @project) + push_frontend_feature_flag(:release_search_filter, @project, default_enabled: true) + push_frontend_feature_flag(:async_mr_widget, @project) end around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions] @@ -218,11 +220,16 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def ci_environments_status - environments = if ci_environments_status_on_merge_result? - EnvironmentStatus.after_merge_request(@merge_request, current_user) - else - EnvironmentStatus.for_merge_request(@merge_request, current_user) - end + environments = + if ci_environments_status_on_merge_result? + if Feature.enabled?(:deployment_merge_requests_widget, @project) + EnvironmentStatus.for_deployed_merge_request(@merge_request, current_user) + else + EnvironmentStatus.after_merge_request(@merge_request, current_user) + end + else + EnvironmentStatus.for_merge_request(@merge_request, current_user) + end render json: EnvironmentStatusSerializer.new(current_user: current_user).represent(environments) end diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb index 722fc30b3ff..f1e591ea1ec 100644 --- a/app/controllers/projects/pages_controller.rb +++ b/app/controllers/projects/pages_controller.rb @@ -15,8 +15,7 @@ class Projects::PagesController < Projects::ApplicationController # rubocop: enable CodeReuse/ActiveRecord def destroy - project.remove_pages - project.pages_domains.destroy_all # rubocop: disable DestroyAll + ::Pages::DeleteService.new(@project, current_user).execute respond_to do |format| format.html do diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb index b693642981e..5a81a064048 100644 --- a/app/controllers/projects/pages_domains_controller.rb +++ b/app/controllers/projects/pages_domains_controller.rb @@ -8,7 +8,6 @@ class Projects::PagesDomainsController < Projects::ApplicationController before_action :domain, except: [:new, :create] def show - redirect_to edit_project_pages_domain_path(@project, @domain) end def new @@ -24,17 +23,18 @@ class Projects::PagesDomainsController < Projects::ApplicationController flash[:alert] = 'Failed to verify domain ownership' end - redirect_to edit_project_pages_domain_path(@project, @domain) + redirect_to project_pages_domain_path(@project, @domain) end def edit + redirect_to project_pages_domain_path(@project, @domain) end def create @domain = @project.pages_domains.create(create_params) if @domain.valid? - redirect_to edit_project_pages_domain_path(@project, @domain) + redirect_to project_pages_domain_path(@project, @domain) else render 'new' end @@ -46,7 +46,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController status: :found, notice: 'Domain was updated' else - render 'edit' + render 'show' end end @@ -68,7 +68,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController flash[:alert] = @domain.errors.full_messages.join(', ') end - redirect_to edit_project_pages_domain_path(@project, @domain) + redirect_to project_pages_domain_path(@project, @domain) end private diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index 72e939a3310..6a7e2b69652 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -83,12 +83,14 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController def play_rate_limit return unless current_user - limiter = ::Gitlab::ActionRateLimiter.new(action: :play_pipeline_schedule) - - return unless limiter.throttled?([current_user, schedule], 1) + if rate_limiter.throttled?(:play_pipeline_schedule, scope: [current_user, schedule]) + flash[:alert] = _('You cannot play this scheduled pipeline at the moment. Please wait a minute.') + redirect_to pipeline_schedules_path(@project) + end + end - flash[:alert] = _('You cannot play this scheduled pipeline at the moment. Please wait a minute.') - redirect_to pipeline_schedules_path(@project) + def rate_limiter + ::Gitlab::ApplicationRateLimiter end def schedule diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 4d35353d5f5..e3ef8f3f2ff 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -11,7 +11,6 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :authorize_create_pipeline!, only: [:new, :create] before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action do - push_frontend_feature_flag(:hide_dismissed_vulnerabilities) push_frontend_feature_flag(:junit_pipeline_view) end diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index b01d48ca3d3..7bd084458d1 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -17,7 +17,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController @skip_groups << @project.namespace_id unless @project.personal? @skip_groups += @project.group.ancestors.pluck(:id) if @project.group - @project_members = MembersFinder.new(@project, current_user).execute + @project_members = MembersFinder.new(@project, current_user).execute(include_relations: requested_relations) if params[:search].present? @project_members = @project_members.joins(:user).merge(User.search(params[:search])) diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb index c94fdd9483d..f39d98be516 100644 --- a/app/controllers/projects/raw_controller.rb +++ b/app/controllers/projects/raw_controller.rb @@ -4,11 +4,15 @@ class Projects::RawController < Projects::ApplicationController include ExtractsPath include SendsBlob + include StaticObjectExternalStorage + + prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:blob) } before_action :require_non_empty_project before_action :assign_ref_vars before_action :authorize_download_code! - before_action :show_rate_limit, only: [:show] + before_action :show_rate_limit, only: [:show], unless: :external_storage_request? + before_action :redirect_to_external_storage, only: :show, if: :static_objects_external_storage_enabled? def show @blob = @repository.blob_at(@commit.id, @path) @@ -19,14 +23,16 @@ class Projects::RawController < Projects::ApplicationController private def show_rate_limit - limiter = ::Gitlab::ActionRateLimiter.new(action: :show_raw_controller) - - return unless limiter.throttled?([@project, @commit, @path], raw_blob_request_limit) + if rate_limiter.throttled?(:show_raw_controller, scope: [@project, @commit, @path], threshold: raw_blob_request_limit) + rate_limiter.log_request(request, :raw_blob_request_limit, current_user) - limiter.log_request(request, :raw_blob_request_limit, current_user) + flash[:alert] = _('You cannot access the raw file. Please wait a minute.') + redirect_to project_blob_path(@project, File.join(@ref, @path)), status: :too_many_requests + end + end - flash[:alert] = _('You cannot access the raw file. Please wait a minute.') - redirect_to project_blob_path(@project, File.join(@ref, @path)), status: :too_many_requests + def rate_limiter + ::Gitlab::ApplicationRateLimiter end def raw_blob_request_limit diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index 72c82aec31d..ffe69fe97e4 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -6,10 +6,11 @@ class Projects::ReleasesController < Projects::ApplicationController before_action :release, only: %i[edit update] before_action :authorize_read_release! before_action do - push_frontend_feature_flag(:release_edit_page, project, default_enabled: true) push_frontend_feature_flag(:release_issue_summary, project) + push_frontend_feature_flag(:release_evidence_collection, project) end before_action :authorize_update_release!, only: %i[edit update] + before_action :authorize_download_code!, only: [:evidence] def index respond_to do |format| @@ -20,6 +21,14 @@ class Projects::ReleasesController < Projects::ApplicationController end end + def evidence + respond_to do |format| + format.json do + render json: release.evidence_summary + end + end + end + protected def releases @@ -35,7 +44,6 @@ class Projects::ReleasesController < Projects::ApplicationController private def authorize_update_release! - access_denied! unless Feature.enabled?(:release_edit_page, project, default_enabled: true) access_denied! unless can?(current_user, :update_release, release) end diff --git a/app/controllers/projects/service_hook_logs_controller.rb b/app/controllers/projects/service_hook_logs_controller.rb new file mode 100644 index 00000000000..5c814ea139f --- /dev/null +++ b/app/controllers/projects/service_hook_logs_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class Projects::ServiceHookLogsController < Projects::HookLogsController + before_action :service, only: [:show, :retry] + + def retry + execute_hook + redirect_to edit_project_service_path(@project, @service) + end + + private + + def hook + @hook ||= service.service_hook + end + + def service + @service ||= @project.find_or_initialize_service(params[:service_id]) + end +end diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb index c9f680a4696..daaca9e1268 100644 --- a/app/controllers/projects/services_controller.rb +++ b/app/controllers/projects/services_controller.rb @@ -7,6 +7,7 @@ class Projects::ServicesController < Projects::ApplicationController before_action :authorize_admin_project! before_action :ensure_service_enabled before_action :service + before_action :web_hook_logs, only: [:edit, :update] respond_to :html @@ -77,6 +78,12 @@ class Projects::ServicesController < Projects::ApplicationController @service ||= @project.find_or_initialize_service(params[:id]) end + def web_hook_logs + return unless @service.service_hook.present? + + @web_hook_logs ||= @service.service_hook.web_hook_logs.recent.page(params[:page]) + end + def ensure_service_enabled render_404 unless service end diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index cfed8727450..6af815b8daa 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -13,7 +13,7 @@ module Projects Projects::UpdateService.new(project, current_user, update_params).tap do |service| result = service.execute if result[:status] == :success - flash[:notice] = _("Pipelines settings for '%{project_name}' were successfully updated.") % { project_name: @project.name } + flash[:toast] = _("Pipelines settings for '%{project_name}' were successfully updated.") % { project_name: @project.name } run_autodevops_pipeline(service) @@ -39,7 +39,7 @@ module Projects def reset_registration_token @project.reset_runners_token! - flash[:notice] = _('New runners registration token has been generated!') + flash[:toast] = _("New runners registration token has been generated!") redirect_to namespace_project_settings_ci_cd_path end @@ -65,12 +65,14 @@ module Projects return unless service.run_auto_devops_pipeline? if @project.empty_repo? - flash[:warning] = _("This repository is currently empty. A new Auto DevOps pipeline will be created after a new file has been pushed to a branch.") + flash[:notice] = _("This repository is currently empty. A new Auto DevOps pipeline will be created after a new file has been pushed to a branch.") return end CreatePipelineWorker.perform_async(project.id, current_user.id, project.default_branch, :web, ignore_skip_ci: true, save_on_errors: false) - flash[:success] = "A new Auto DevOps pipeline has been created, go to <a href=\"#{project_pipelines_path(@project)}\">Pipelines page</a> for details".html_safe + + pipelines_link_start = '<a href="%{url}">'.html_safe % { url: project_pipelines_path(@project) } + flash[:toast] = _("A new Auto DevOps pipeline has been created, go to %{pipelines_link_start}Pipelines page%{pipelines_link_end} for details") % { pipelines_link_start: pipelines_link_start, pipelines_link_end: "</a>".html_safe } end def define_variables diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index e5dea031bb5..47d6fb67108 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -32,6 +32,9 @@ class ProjectsController < Projects::ApplicationController before_action :authorize_archive_project!, only: [:archive, :unarchive] before_action :event_filter, only: [:show, :activity] + # Project Export Rate Limit + before_action :export_rate_limit, only: [:export, :download_export, :generate_new_export] + layout :determine_layout def index @@ -465,6 +468,21 @@ class ProjectsController < Projects::ApplicationController def present_project @project = @project.present(current_user: current_user) end + + def export_rate_limit + prefixed_action = "project_#{params[:action]}".to_sym + + if rate_limiter.throttled?(prefixed_action, scope: [current_user, prefixed_action, @project]) + rate_limiter.log_request(request, "#{prefixed_action}_request_limit".to_sym, current_user) + + flash[:alert] = _('This endpoint has been requested too many times. Try again later.') + redirect_to edit_project_path(@project) + end + end + + def rate_limiter + ::Gitlab::ApplicationRateLimiter + end end ProjectsController.prepend_if_ee('EE::ProjectsController') diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb index 5805d068e21..54774df5e76 100644 --- a/app/controllers/snippets_controller.rb +++ b/app/controllers/snippets_controller.rb @@ -15,13 +15,9 @@ class SnippetsController < ApplicationController before_action :snippet, only: [:show, :edit, :destroy, :update, :raw] - # Allow read snippet + before_action :authorize_create_snippet!, only: [:new, :create] before_action :authorize_read_snippet!, only: [:show, :raw] - - # Allow modify snippet before_action :authorize_update_snippet!, only: [:edit, :update] - - # Allow destroy snippet before_action :authorize_admin_snippet!, only: [:destroy] skip_before_action :authenticate_user!, only: [:index, :show, :raw] @@ -140,6 +136,10 @@ class SnippetsController < ApplicationController return render_404 unless can?(current_user, :admin_personal_snippet, @snippet) end + def authorize_create_snippet! + return render_404 unless can?(current_user, :create_personal_snippet) + end + def snippet_params params.require(:personal_snippet).permit(:title, :content, :file_name, :private, :visibility_level, :description) end diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index 635db386792..67d33648470 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -20,7 +20,6 @@ class UploadsController < ApplicationController skip_before_action :authenticate_user! before_action :upload_mount_satisfied? - before_action :find_model before_action :authorize_access!, only: [:show] before_action :authorize_create_access!, only: [:create, :authorize] before_action :verify_workhorse_api!, only: [:authorize] diff --git a/app/finders/clusters/knative_serving_namespace_finder.rb b/app/finders/clusters/knative_serving_namespace_finder.rb new file mode 100644 index 00000000000..d3db5be558c --- /dev/null +++ b/app/finders/clusters/knative_serving_namespace_finder.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Clusters + class KnativeServingNamespaceFinder + attr_reader :cluster + + def initialize(cluster) + @cluster = cluster + end + + def execute + cluster.kubeclient&.get_namespace(Clusters::Kubernetes::KNATIVE_SERVING_NAMESPACE) + rescue Kubeclient::ResourceNotFoundError + nil + end + end +end diff --git a/app/finders/clusters/knative_version_role_binding_finder.rb b/app/finders/clusters/knative_version_role_binding_finder.rb new file mode 100644 index 00000000000..26f5492840a --- /dev/null +++ b/app/finders/clusters/knative_version_role_binding_finder.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Clusters + class KnativeVersionRoleBindingFinder + attr_reader :cluster + + def initialize(cluster) + @cluster = cluster + end + + def execute + cluster.kubeclient&.get_cluster_role_binding(Clusters::Kubernetes::GITLAB_KNATIVE_VERSION_ROLE_BINDING_NAME) + rescue Kubeclient::ResourceNotFoundError + nil + end + end +end diff --git a/app/finders/deployments_finder.rb b/app/finders/deployments_finder.rb new file mode 100644 index 00000000000..b718b55dd68 --- /dev/null +++ b/app/finders/deployments_finder.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +class DeploymentsFinder + attr_reader :project, :params + + ALLOWED_SORT_VALUES = %w[id iid created_at updated_at ref].freeze + DEFAULT_SORT_VALUE = 'id'.freeze + + ALLOWED_SORT_DIRECTIONS = %w[asc desc].freeze + DEFAULT_SORT_DIRECTION = 'asc'.freeze + + def initialize(project, params = {}) + @project = project + @params = params + end + + def execute + items = init_collection + items = by_updated_at(items) + sort(items) + end + + private + + # rubocop: disable CodeReuse/ActiveRecord + def init_collection + project + .deployments + .includes( + :user, + environment: [], + deployable: { + job_artifacts: [], + pipeline: { + project: { + route: [], + namespace: :route + } + }, + project: { + namespace: :route + } + } + ) + end + # rubocop: enable CodeReuse/ActiveRecord + + # rubocop: disable CodeReuse/ActiveRecord + def sort(items) + items.order(sort_params) + end + # rubocop: enable CodeReuse/ActiveRecord + + def by_updated_at(items) + items = items.updated_before(params[:updated_before]) if params[:updated_before].present? + items = items.updated_after(params[:updated_after]) if params[:updated_after].present? + + items + end + + def sort_params + order_by = ALLOWED_SORT_VALUES.include?(params[:order_by]) ? params[:order_by] : DEFAULT_SORT_VALUE + order_direction = ALLOWED_SORT_DIRECTIONS.include?(params[:sort]) ? params[:sort] : DEFAULT_SORT_DIRECTION + + { order_by => order_direction }.tap do |sort_values| + sort_values['id'] = 'desc' if sort_values['updated_at'] + sort_values['id'] = sort_values.delete('created_at') if sort_values['created_at'] # Sorting by `id` produces the same result as sorting by `created_at` + end + end +end diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb index 165d9adae31..d8739c350e4 100644 --- a/app/finders/group_members_finder.rb +++ b/app/finders/group_members_finder.rb @@ -6,15 +6,15 @@ class GroupMembersFinder < UnionFinder end # rubocop: disable CodeReuse/ActiveRecord - def execute(include_descendants: false) + def execute(include_relations: [:inherited, :direct]) group_members = @group.members relations = [] - return group_members unless @group.parent || include_descendants + return group_members if include_relations == [:direct] - relations << group_members + relations << group_members if include_relations.include?(:direct) - if @group.parent + if include_relations.include?(:inherited) && @group.parent parents_members = GroupMember.non_request .where(source_id: @group.ancestors.select(:id)) .where.not(user_id: @group.users.select(:id)) @@ -22,7 +22,7 @@ class GroupMembersFinder < UnionFinder relations << parents_members end - if include_descendants + if include_relations.include?(:descendants) descendant_members = GroupMember.non_request .where(source_id: @group.descendants.select(:id)) .where.not(user_id: @group.users.select(:id)) diff --git a/app/finders/group_projects_finder.rb b/app/finders/group_projects_finder.rb index 8ab5072fdc6..dd8b2f29425 100644 --- a/app/finders/group_projects_finder.rb +++ b/app/finders/group_projects_finder.rb @@ -11,6 +11,7 @@ # options: # only_owned: boolean # only_shared: boolean +# limit: integer # params: # sort: string # visibility_level: int @@ -20,6 +21,8 @@ # non_archived: boolean # class GroupProjectsFinder < ProjectsFinder + DEFAULT_PROJECTS_LIMIT = 100 + attr_reader :group, :options def initialize(group:, params: {}, options: {}, current_user: nil, project_ids_relation: nil) @@ -32,8 +35,19 @@ class GroupProjectsFinder < ProjectsFinder @options = options end + def execute + collection = super + limit(collection) + end + private + def limit(collection) + limit = options[:limit] + + limit.present? ? collection.with_limit(limit) : collection + end + def init_collection projects = if current_user collection_with_user diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb index 7d419103b1c..54715557399 100644 --- a/app/finders/groups_finder.rb +++ b/app/finders/groups_finder.rb @@ -45,7 +45,7 @@ class GroupsFinder < UnionFinder def all_groups return [owned_groups] if params[:owned] return [groups_with_min_access_level] if min_access_level? - return [Group.all] if current_user&.full_private_access? && all_available? + return [Group.all] if current_user&.can_read_all_resources? && all_available? groups = [] groups << Gitlab::ObjectHierarchy.new(groups_for_ancestors, groups_for_descendants).all_objects if current_user diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index dfddd32d7df..e3ea81d5564 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -13,6 +13,7 @@ # group_id: integer # project_id: integer # milestone_title: string +# release_tag: string # author_id: integer # author_username: string # assignee_id: integer or 'None' or 'Any' @@ -59,6 +60,7 @@ class IssuableFinder author_username label_name milestone_title + release_tag my_reaction_emoji search in @@ -126,6 +128,7 @@ class IssuableFinder items = by_non_archived(items) items = by_iids(items) items = by_milestone(items) + items = by_release(items) items = by_label(items) by_my_reaction_emoji(items) end @@ -364,6 +367,10 @@ class IssuableFinder end end + def releases? + params[:release_tag].present? + end + private def force_cte? @@ -570,6 +577,18 @@ class IssuableFinder end # rubocop: enable CodeReuse/ActiveRecord + def by_release(items) + return items unless releases? + + if filter_by_no_release? + items.without_release + elsif filter_by_any_release? + items.any_release + else + items.with_release(params[:release_tag], params[:project_id]) + end + end + def filter_by_no_milestone? # Accepts `No Milestone` for compatibility params[:milestone_title].to_s.downcase == FILTER_NONE || params[:milestone_title] == Milestone::None.title @@ -588,6 +607,14 @@ class IssuableFinder params[:milestone_title] == Milestone::Started.name end + def filter_by_no_release? + params[:release_tag].to_s.downcase == FILTER_NONE + end + + def filter_by_any_release? + params[:release_tag].to_s.downcase == FILTER_ANY + end + def by_label(items) return items unless labels? diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index 74e89a1e66c..641b4422db9 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -127,7 +127,7 @@ class IssuesFinder < IssuableFinder return @user_can_see_all_confidential_issues if defined?(@user_can_see_all_confidential_issues) return @user_can_see_all_confidential_issues = false if current_user.blank? - return @user_can_see_all_confidential_issues = true if current_user.full_private_access? + return @user_can_see_all_confidential_issues = true if current_user.can_read_all_resources? @user_can_see_all_confidential_issues = if project? && project diff --git a/app/finders/jobs_finder.rb b/app/finders/jobs_finder.rb new file mode 100644 index 00000000000..bac18e69618 --- /dev/null +++ b/app/finders/jobs_finder.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class JobsFinder + include Gitlab::Allowable + + def initialize(current_user:, project: nil, params: {}) + @current_user = current_user + @project = project + @params = params + end + + def execute + builds = init_collection.order_id_desc + filter_by_scope(builds) + rescue Gitlab::Access::AccessDeniedError + Ci::Build.none + end + + private + + attr_reader :current_user, :project, :params + + def init_collection + project ? project_builds : all_builds + end + + def all_builds + raise Gitlab::Access::AccessDeniedError unless current_user&.admin? + + Ci::Build.all + end + + def project_builds + raise Gitlab::Access::AccessDeniedError unless can?(current_user, :read_build, project) + + project.builds.relevant + end + + def filter_by_scope(builds) + case params[:scope] + when 'pending' + builds.pending.reverse_order + when 'running' + builds.running.reverse_order + when 'finished' + builds.finished + else + builds + end + end +end diff --git a/app/finders/keys_finder.rb b/app/finders/keys_finder.rb new file mode 100644 index 00000000000..6fd914c88cd --- /dev/null +++ b/app/finders/keys_finder.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true +class KeysFinder + InvalidFingerprint = Class.new(StandardError) + GitLabAccessDeniedError = Class.new(StandardError) + + FINGERPRINT_ATTRIBUTES = { + 'sha256' => 'fingerprint_sha256', + 'md5' => 'fingerprint' + }.freeze + + def initialize(current_user, params) + @current_user = current_user + @params = params + end + + def execute + raise GitLabAccessDeniedError unless current_user.admin? + + keys = by_key_type + keys = by_user(keys) + keys = sort(keys) + + by_fingerprint(keys) + end + + private + + attr_reader :current_user, :params + + def by_key_type + if params[:key_type] == 'ssh' + Key.regular_keys + else + Key.all + end + end + + def sort(keys) + keys.order_last_used_at_desc + end + + def by_user(keys) + return keys unless params[:user] + + keys.for_user(params[:user]) + end + + def by_fingerprint(keys) + return keys unless params[:fingerprint].present? + raise InvalidFingerprint unless valid_fingerprint_param? + + keys.where(fingerprint_query).first # rubocop: disable CodeReuse/ActiveRecord + end + + def valid_fingerprint_param? + if fingerprint_type == "sha256" + Base64.decode64(fingerprint).length == 32 + else + fingerprint =~ /^(\h{2}:){15}\h{2}/ + end + end + + def fingerprint_query + fingerprint_attribute = FINGERPRINT_ATTRIBUTES[fingerprint_type] + + Key.arel_table[fingerprint_attribute].eq(fingerprint) + end + + def fingerprint_type + if params[:fingerprint].start_with?(/sha256:|SHA256:/) + "sha256" + else + "md5" + end + end + + def fingerprint + if fingerprint_type == "sha256" + params[:fingerprint].gsub(/sha256:|SHA256:/, "") + else + params[:fingerprint] + end + end +end diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb index e8c7f9622a9..a919ff5bf8a 100644 --- a/app/finders/members_finder.rb +++ b/app/finders/members_finder.rb @@ -9,14 +9,18 @@ class MembersFinder @group = project.group end - def execute(include_descendants: false, include_invited_groups_members: false) + def execute(include_relations: [:inherited, :direct]) project_members = project.project_members project_members = project_members.non_invite unless can?(current_user, :admin_project, project) - union_members = group_union_members(include_descendants, include_invited_groups_members) + return project_members if include_relations == [:direct] + + union_members = group_union_members(include_relations) + + union_members << project_members if include_relations.include?(:direct) if union_members.any? - distinct_union_of_members(union_members << project_members) + distinct_union_of_members(union_members) else project_members end @@ -28,15 +32,17 @@ class MembersFinder private - def group_union_members(include_descendants, include_invited_groups_members) + def group_union_members(include_relations) [].tap do |members| - members << direct_group_members(include_descendants) if group - members << project_invited_groups_members if include_invited_groups_members + members << direct_group_members(include_relations.include?(:descendants)) if group + members << project_invited_groups_members if include_relations.include?(:invited_groups_members) end end def direct_group_members(include_descendants) - GroupMembersFinder.new(group).execute(include_descendants: include_descendants).non_invite # rubocop: disable CodeReuse/Finder + requested_relations = [:inherited, :direct] + requested_relations << :descendants if include_descendants + GroupMembersFinder.new(group).execute(include_relations: requested_relations).non_invite # rubocop: disable CodeReuse/Finder end def project_invited_groups_members diff --git a/app/finders/merge_request_target_project_finder.rb b/app/finders/merge_request_target_project_finder.rb index 5f0589f6c8b..85a73e0c6ff 100644 --- a/app/finders/merge_request_target_project_finder.rb +++ b/app/finders/merge_request_target_project_finder.rb @@ -11,15 +11,23 @@ class MergeRequestTargetProjectFinder end # rubocop: disable CodeReuse/ActiveRecord - def execute - if @source_project.fork_network - @source_project.fork_network.projects - .public_or_visible_to_user(current_user) - .non_archived - .with_feature_available_for_user(:merge_requests, current_user) + def execute(include_routes: false) + if source_project.fork_network + include_routes ? projects.inc_routes : projects else Project.where(id: source_project) end end # rubocop: enable CodeReuse/ActiveRecord + + private + + def projects + source_project + .fork_network + .projects + .public_or_visible_to_user(current_user) + .non_archived + .with_feature_available_for_user(:merge_requests, current_user) + end end diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index 1c9c7ec68d0..275a01330bf 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -12,6 +12,7 @@ # group_id: integer # project_id: integer # milestone_title: string +# release_tag: string # author_id: integer # assignee_id: integer # search: string diff --git a/app/finders/personal_access_tokens_finder.rb b/app/finders/personal_access_tokens_finder.rb index bd95dcd323f..7b15a3b0c10 100644 --- a/app/finders/personal_access_tokens_finder.rb +++ b/app/finders/personal_access_tokens_finder.rb @@ -13,18 +13,26 @@ class PersonalAccessTokensFinder tokens = PersonalAccessToken.all tokens = by_user(tokens) tokens = by_impersonation(tokens) - by_state(tokens) + tokens = by_state(tokens) + + sort(tokens) end private - # rubocop: disable CodeReuse/ActiveRecord def by_user(tokens) return tokens unless @params[:user] - tokens.where(user: @params[:user]) + tokens.for_user(@params[:user]) + end + + def sort(tokens) + available_sort_orders = PersonalAccessToken.simple_sorts.keys + + return tokens unless available_sort_orders.include?(params[:sort]) + + tokens.order_by(params[:sort]) end - # rubocop: enable CodeReuse/ActiveRecord def by_impersonation(tokens) case @params[:impersonation] diff --git a/app/finders/pipelines_finder.rb b/app/finders/pipelines_finder.rb index f5aadc42ff0..5a0d53d9683 100644 --- a/app/finders/pipelines_finder.rb +++ b/app/finders/pipelines_finder.rb @@ -3,7 +3,7 @@ class PipelinesFinder attr_reader :project, :pipelines, :params, :current_user - ALLOWED_INDEXED_COLUMNS = %w[id status ref user_id].freeze + ALLOWED_INDEXED_COLUMNS = %w[id status ref updated_at user_id].freeze def initialize(project, current_user, params = {}) @project = project @@ -25,6 +25,7 @@ class PipelinesFinder items = by_name(items) items = by_username(items) items = by_yaml_errors(items) + items = by_updated_at(items) sort_items(items) end @@ -128,6 +129,13 @@ class PipelinesFinder end # rubocop: enable CodeReuse/ActiveRecord + def by_updated_at(items) + items = items.updated_before(params[:updated_before]) if params[:updated_before].present? + items = items.updated_after(params[:updated_after]) if params[:updated_after].present? + + items + end + # rubocop: disable CodeReuse/ActiveRecord def sort_items(items) order_by = if ALLOWED_INDEXED_COLUMNS.include?(params[:order_by]) diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 42a15234e57..ac18c17dc61 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -79,7 +79,7 @@ class ProjectsFinder < UnionFinder elsif min_access_level? current_user.authorized_projects(params[:min_access_level]) else - if private_only? + if private_only? || impossible_visibility_level? current_user.authorized_projects else Project.public_or_visible_to_user(current_user) @@ -96,6 +96,30 @@ class ProjectsFinder < UnionFinder end end + # This is an optimization - surprisingly PostgreSQL does not optimize + # for this. + # + # If the default visiblity level and desired visiblity level filter cancels + # each other out, don't use the SQL clause for visibility level in + # `Project.public_or_visible_to_user`. In fact, this then becames equivalent + # to just authorized projects for the user. + # + # E.g. + # (EXISTS(<authorized_projects>) OR projects.visibility_level IN (10,20)) + # AND "projects"."visibility_level" = 0 + # + # is essentially + # EXISTS(<authorized_projects>) AND "projects"."visibility_level" = 0 + # + # See https://gitlab.com/gitlab-org/gitlab/issues/37007 + def impossible_visibility_level? + return unless params[:visibility_level].present? + + public_visibility_levels = Gitlab::VisibilityLevel.levels_for_user(current_user) + + !public_visibility_levels.include?(params[:visibility_level]) + end + def owned_projects? params[:owned].present? end diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb index bd6b6190fb5..5819f279eaa 100644 --- a/app/finders/snippets_finder.rb +++ b/app/finders/snippets_finder.rb @@ -40,15 +40,14 @@ # Any other value will be ignored. class SnippetsFinder < UnionFinder include FinderMethods + include Gitlab::Utils::StrongMemoize - attr_accessor :current_user, :project, :author, :scope, :explore + attr_accessor :current_user, :params + delegate :explore, :only_personal, :only_project, :scope, to: :params def initialize(current_user = nil, params = {}) @current_user = current_user - @project = params[:project] - @author = params[:author] - @scope = params[:scope].to_s - @explore = params[:explore] + @params = OpenStruct.new(params) if project && author raise( @@ -60,8 +59,15 @@ class SnippetsFinder < UnionFinder end def execute - base = init_collection - base.with_optional_visibility(visibility_from_scope).fresh + # The snippet query can be expensive, therefore if the + # author or project params have been passed and they don't + # exist, it's better to return + return Snippet.none if author.nil? && params[:author].present? + return Snippet.none if project.nil? && params[:project].present? + + items = init_collection + items = by_ids(items) + items.with_optional_visibility(visibility_from_scope).fresh end private @@ -69,10 +75,12 @@ class SnippetsFinder < UnionFinder def init_collection if explore snippets_for_explore + elsif only_personal + personal_snippets elsif project snippets_for_a_single_project else - snippets_for_multiple_projects + snippets_for_personal_and_multiple_projects end end @@ -96,8 +104,9 @@ class SnippetsFinder < UnionFinder # # Each collection is constructed in isolation, allowing for greater control # over the resulting SQL query. - def snippets_for_multiple_projects - queries = [personal_snippets] + def snippets_for_personal_and_multiple_projects + queries = [] + queries << personal_snippets unless only_project if Ability.allowed?(current_user, :read_cross_project) queries << snippets_of_visible_projects @@ -158,7 +167,7 @@ class SnippetsFinder < UnionFinder end def visibility_from_scope - case scope + case scope.to_s when 'are_private' Snippet::PRIVATE when 'are_internal' @@ -169,6 +178,28 @@ class SnippetsFinder < UnionFinder nil end end + + def by_ids(items) + return items unless params[:ids].present? + + items.id_in(params[:ids]) + end + + def author + strong_memoize(:author) do + next unless params[:author].present? + + params[:author].is_a?(User) ? params[:author] : User.find_by_id(params[:author]) + end + end + + def project + strong_memoize(:project) do + next unless params[:project].present? + + params[:project].is_a?(Project) ? params[:project] : Project.find_by_id(params[:project]) + end + end end SnippetsFinder.prepend_if_ee('EE::SnippetsFinder') diff --git a/app/finders/user_finder.rb b/app/finders/user_finder.rb index 1dd1a27437e..556be4c4338 100644 --- a/app/finders/user_finder.rb +++ b/app/finders/user_finder.rb @@ -52,12 +52,6 @@ class UserFinder end end - def find_by_ssh_key_id - return unless input_is_id? - - User.find_by_ssh_key_id(@username_or_id) - end - def input_is_id? @username_or_id.is_a?(Numeric) || @username_or_id =~ /^\d+$/ end diff --git a/app/graphql/mutations/issues/base.rb b/app/graphql/mutations/issues/base.rb new file mode 100644 index 00000000000..b7fa234a50b --- /dev/null +++ b/app/graphql/mutations/issues/base.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Mutations + module Issues + class Base < BaseMutation + include Mutations::ResolvesProject + + argument :project_path, GraphQL::ID_TYPE, + required: true, + description: "The project the issue to mutate is in" + + argument :iid, GraphQL::STRING_TYPE, + required: true, + description: "The iid of the issue to mutate" + + field :issue, + Types::IssueType, + null: true, + description: "The issue after mutation" + + authorize :update_issue + + private + + def find_object(project_path:, iid:) + project = resolve_project(full_path: project_path) + resolver = Resolvers::IssuesResolver + .single.new(object: project, context: context) + + resolver.resolve(iid: iid) + end + end + end +end diff --git a/app/graphql/mutations/issues/set_confidential.rb b/app/graphql/mutations/issues/set_confidential.rb new file mode 100644 index 00000000000..0fff5518665 --- /dev/null +++ b/app/graphql/mutations/issues/set_confidential.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Mutations + module Issues + class SetConfidential < Base + graphql_name 'IssueSetConfidential' + + argument :confidential, + GraphQL::BOOLEAN_TYPE, + required: true, + description: 'Whether or not to set the issue as a confidential.' + + def resolve(project_path:, iid:, confidential:) + issue = authorized_find!(project_path: project_path, iid: iid) + project = issue.project + + ::Issues::UpdateService.new(project, current_user, confidential: confidential) + .execute(issue) + + { + issue: issue, + errors: issue.errors.full_messages + } + end + end + end +end diff --git a/app/graphql/mutations/issues/set_due_date.rb b/app/graphql/mutations/issues/set_due_date.rb new file mode 100644 index 00000000000..1855c6f053b --- /dev/null +++ b/app/graphql/mutations/issues/set_due_date.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Mutations + module Issues + class SetDueDate < Base + graphql_name 'IssueSetDueDate' + + argument :due_date, + Types::TimeType, + required: true, + description: 'The desired due date for the issue' + + def resolve(project_path:, iid:, due_date:) + issue = authorized_find!(project_path: project_path, iid: iid) + project = issue.project + + ::Issues::UpdateService.new(project, current_user, due_date: due_date) + .execute(issue) + + { + issue: issue, + errors: issue.errors.full_messages + } + end + end + end +end diff --git a/app/graphql/mutations/snippets/base.rb b/app/graphql/mutations/snippets/base.rb new file mode 100644 index 00000000000..9dc6d49774e --- /dev/null +++ b/app/graphql/mutations/snippets/base.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Mutations + module Snippets + class Base < BaseMutation + field :snippet, + Types::SnippetType, + null: true, + description: 'The snippet after mutation' + + private + + def find_object(id:) + GitlabSchema.object_from_id(id) + end + + def authorized_resource?(snippet) + Ability.allowed?(context[:current_user], ability_for(snippet), snippet) + end + + def ability_for(snippet) + "#{ability_name}_#{snippet.to_ability_name}".to_sym + end + + def ability_name + raise NotImplementedError + end + end + end +end diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb new file mode 100644 index 00000000000..fe1f543ea1a --- /dev/null +++ b/app/graphql/mutations/snippets/create.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Mutations + module Snippets + class Create < BaseMutation + include Mutations::ResolvesProject + + graphql_name 'CreateSnippet' + + field :snippet, + Types::SnippetType, + null: true, + description: 'The snippet after mutation' + + argument :title, GraphQL::STRING_TYPE, + required: true, + description: 'Title of the snippet' + + argument :file_name, GraphQL::STRING_TYPE, + required: false, + description: 'File name of the snippet' + + argument :content, GraphQL::STRING_TYPE, + required: true, + description: 'Content of the snippet' + + argument :description, GraphQL::STRING_TYPE, + required: false, + description: 'Description of the snippet' + + argument :visibility_level, Types::VisibilityLevelsEnum, + description: 'The visibility level of the snippet', + required: true + + argument :project_path, GraphQL::ID_TYPE, + required: false, + description: 'The project full path the snippet is associated with' + + def resolve(args) + project_path = args.delete(:project_path) + + if project_path.present? + project = find_project!(project_path: project_path) + elsif !can_create_personal_snippet? + raise_resource_not_avaiable_error! + end + + snippet = CreateSnippetService.new(project, + context[:current_user], + args).execute + + { + snippet: snippet.valid? ? snippet : nil, + errors: errors_on_object(snippet) + } + end + + private + + def find_project!(project_path:) + authorized_find!(full_path: project_path) + end + + def find_object(full_path:) + resolve_project(full_path: full_path) + end + + def authorized_resource?(project) + Ability.allowed?(context[:current_user], :create_project_snippet, project) + end + + def can_create_personal_snippet? + Ability.allowed?(context[:current_user], :create_personal_snippet) + end + end + end +end diff --git a/app/graphql/mutations/snippets/destroy.rb b/app/graphql/mutations/snippets/destroy.rb new file mode 100644 index 00000000000..115fcfd6488 --- /dev/null +++ b/app/graphql/mutations/snippets/destroy.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Mutations + module Snippets + class Destroy < Base + graphql_name 'DestroySnippet' + + ERROR_MSG = 'Error deleting the snippet' + + argument :id, + GraphQL::ID_TYPE, + required: true, + description: 'The global id of the snippet to destroy' + + def resolve(id:) + snippet = authorized_find!(id: id) + + result = snippet.destroy + errors = result ? [] : [ERROR_MSG] + + { + errors: errors + } + end + + private + + def ability_name + "admin" + end + end + end +end diff --git a/app/graphql/mutations/snippets/mark_as_spam.rb b/app/graphql/mutations/snippets/mark_as_spam.rb new file mode 100644 index 00000000000..260a9753f76 --- /dev/null +++ b/app/graphql/mutations/snippets/mark_as_spam.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Mutations + module Snippets + class MarkAsSpam < Base + graphql_name 'MarkAsSpamSnippet' + + argument :id, + GraphQL::ID_TYPE, + required: true, + description: 'The global id of the snippet to update' + + def resolve(id:) + snippet = authorized_find!(id: id) + + result = mark_as_spam(snippet) + errors = result ? [] : ['Error with Akismet. Please check the logs for more info.'] + + { + errors: errors + } + end + + private + + def mark_as_spam(snippet) + SpamService.new(snippet).mark_as_spam! + end + + def authorized_resource?(snippet) + super && snippet.submittable_as_spam_by?(context[:current_user]) + end + + def ability_name + "admin" + end + end + end +end diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb new file mode 100644 index 00000000000..27c232bc7f8 --- /dev/null +++ b/app/graphql/mutations/snippets/update.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Mutations + module Snippets + class Update < Base + graphql_name 'UpdateSnippet' + + argument :id, + GraphQL::ID_TYPE, + required: true, + description: 'The global id of the snippet to update' + + argument :title, GraphQL::STRING_TYPE, + required: false, + description: 'Title of the snippet' + + argument :file_name, GraphQL::STRING_TYPE, + required: false, + description: 'File name of the snippet' + + argument :content, GraphQL::STRING_TYPE, + required: false, + description: 'Content of the snippet' + + argument :description, GraphQL::STRING_TYPE, + required: false, + description: 'Description of the snippet' + + argument :visibility_level, Types::VisibilityLevelsEnum, + description: 'The visibility level of the snippet', + required: false + + def resolve(args) + snippet = authorized_find!(id: args.delete(:id)) + + result = UpdateSnippetService.new(snippet.project, + context[:current_user], + snippet, + args).execute + + { + snippet: result ? snippet : snippet.reset, + errors: errors_on_object(snippet) + } + end + + private + + def ability_name + "update" + end + end + end +end diff --git a/app/graphql/mutations/todos/base.rb b/app/graphql/mutations/todos/base.rb index b6c7b320be1..2a72019fbac 100644 --- a/app/graphql/mutations/todos/base.rb +++ b/app/graphql/mutations/todos/base.rb @@ -9,6 +9,12 @@ module Mutations GitlabSchema.object_from_id(id) end + def map_to_global_ids(ids) + return [] if ids.blank? + + ids.map { |id| to_global_id(id) } + end + def to_global_id(id) ::URI::GID.build(app: GlobalID.app, model_name: Todo.name, model_id: id, params: nil).to_s end diff --git a/app/graphql/mutations/todos/mark_all_done.rb b/app/graphql/mutations/todos/mark_all_done.rb new file mode 100644 index 00000000000..5694985717c --- /dev/null +++ b/app/graphql/mutations/todos/mark_all_done.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Mutations + module Todos + class MarkAllDone < ::Mutations::Todos::Base + graphql_name 'TodosMarkAllDone' + + authorize :update_user + + field :updated_ids, + [GraphQL::ID_TYPE], + null: false, + description: 'Ids of the updated todos' + + def resolve + authorize!(current_user) + + updated_ids = mark_all_todos_done + + { + updated_ids: map_to_global_ids(updated_ids), + errors: [] + } + end + + private + + def mark_all_todos_done + return [] unless current_user + + TodoService.new.mark_all_todos_as_done_by_user(current_user) + end + end + end +end diff --git a/app/graphql/mutations/todos/mark_done.rb b/app/graphql/mutations/todos/mark_done.rb index 5483708b5c6..d738e387c43 100644 --- a/app/graphql/mutations/todos/mark_done.rb +++ b/app/graphql/mutations/todos/mark_done.rb @@ -16,22 +16,21 @@ module Mutations null: false, description: 'The requested todo' - # rubocop: disable CodeReuse/ActiveRecord def resolve(id:) todo = authorized_find!(id: id) - mark_done(Todo.where(id: todo.id)) unless todo.done? + + mark_done(todo) { todo: todo.reset, errors: errors_on_object(todo) } end - # rubocop: enable CodeReuse/ActiveRecord private def mark_done(todo) - TodoService.new.mark_todos_as_done(todo, current_user) + TodoService.new.mark_todo_as_done(todo, current_user) end end end diff --git a/app/graphql/mutations/todos/restore.rb b/app/graphql/mutations/todos/restore.rb new file mode 100644 index 00000000000..c4597bd84a2 --- /dev/null +++ b/app/graphql/mutations/todos/restore.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Mutations + module Todos + class Restore < ::Mutations::Todos::Base + graphql_name 'TodoRestore' + + authorize :update_todo + + argument :id, + GraphQL::ID_TYPE, + required: true, + description: 'The global id of the todo to restore' + + field :todo, Types::TodoType, + null: false, + description: 'The requested todo' + + def resolve(id:) + todo = authorized_find!(id: id) + restore(todo.id) if todo.done? + + { + todo: todo.reset, + errors: errors_on_object(todo) + } + end + + private + + def restore(id) + TodoService.new.mark_todos_as_pending_by_ids([id], current_user) + end + end + end +end diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb index 85d6b377934..62dcc41dd9c 100644 --- a/app/graphql/resolvers/base_resolver.rb +++ b/app/graphql/resolvers/base_resolver.rb @@ -2,6 +2,8 @@ module Resolvers class BaseResolver < GraphQL::Schema::Resolver + extend ::Gitlab::Utils::Override + def self.single @single ||= Class.new(self) do def resolve(**args) @@ -36,5 +38,13 @@ module Resolvers # complexity difference is minimal in this case. [args[:iid], args[:iids]].any? ? 0 : 0.01 end + + override :object + def object + super.tap do |obj| + # If the field this resolver is used in is wrapped in a presenter, go back to it's subject + break obj.subject if obj.is_a?(Gitlab::View::Presenter::Base) + end + end end end diff --git a/app/graphql/resolvers/concerns/resolves_snippets.rb b/app/graphql/resolvers/concerns/resolves_snippets.rb new file mode 100644 index 00000000000..483372bbf63 --- /dev/null +++ b/app/graphql/resolvers/concerns/resolves_snippets.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module ResolvesSnippets + extend ActiveSupport::Concern + + included do + type Types::SnippetType, null: false + + argument :ids, [GraphQL::ID_TYPE], + required: false, + description: 'Array of global snippet ids, e.g., "gid://gitlab/ProjectSnippet/1"' + + argument :visibility, Types::Snippets::VisibilityScopesEnum, + required: false, + description: 'The visibility of the snippet' + end + + def resolve(**args) + resolve_snippets(args) + end + + private + + def resolve_snippets(args) + SnippetsFinder.new(context[:current_user], snippet_finder_params(args)).execute + end + + def snippet_finder_params(args) + { + ids: resolve_ids(args[:ids]), + scope: args[:visibility] + }.merge(options_by_type(args[:type])) + end + + def resolve_ids(ids) + Array.wrap(ids).map { |id| resolve_gid(id, :id) } + end + + def resolve_gid(gid, argument) + return unless gid.present? + + GlobalID.parse(gid)&.model_id.tap do |id| + raise Gitlab::Graphql::Errors::ArgumentError, "Invalid global id format for param #{argument}" if id.nil? + end + end + + def options_by_type(type) + case type + when 'personal' + { only_personal: true } + when 'project' + { only_project: true } + else + {} + end + end +end diff --git a/app/graphql/resolvers/echo_resolver.rb b/app/graphql/resolvers/echo_resolver.rb index 2ce55544254..fe0b1893a23 100644 --- a/app/graphql/resolvers/echo_resolver.rb +++ b/app/graphql/resolvers/echo_resolver.rb @@ -2,9 +2,11 @@ module Resolvers class EchoResolver < BaseResolver - argument :text, GraphQL::STRING_TYPE, required: true # rubocop:disable Graphql/Descriptions description 'Testing endpoint to validate the API with' + argument :text, GraphQL::STRING_TYPE, required: true, + description: 'Text to echo back' + def resolve(**args) username = context[:current_user]&.username diff --git a/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb new file mode 100644 index 00000000000..63455ff3acb --- /dev/null +++ b/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Resolvers + module ErrorTracking + class SentryDetailedErrorResolver < BaseResolver + argument :id, GraphQL::ID_TYPE, + required: true, + description: 'ID of the Sentry issue' + + def resolve(**args) + project = object + current_user = context[:current_user] + issue_id = GlobalID.parse(args[:id]).model_id + + # Get data from Sentry + response = ::ErrorTracking::IssueDetailsService.new( + project, + current_user, + { issue_id: issue_id } + ).execute + issue = response[:issue] + issue.gitlab_project = project if issue + + issue + end + end + end +end diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index 1fbc61cd950..664e0955535 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -4,17 +4,17 @@ module Resolvers class IssuesResolver < BaseResolver argument :iid, GraphQL::STRING_TYPE, required: false, - description: 'The IID of the issue, e.g., "1"' + description: 'IID of the issue. For example, "1"' argument :iids, [GraphQL::STRING_TYPE], required: false, - description: 'The list of IIDs of issues, e.g., [1, 2]' + description: 'List of IIDs of issues. For example, [1, 2]' argument :state, Types::IssuableStateEnum, required: false, - description: 'Current state of Issue' + description: 'Current state of this issue' argument :label_name, GraphQL::STRING_TYPE.to_list_type, required: false, - description: 'Labels applied to the Issue' + description: 'Labels applied to this issue' argument :created_before, Types::TimeType, required: false, description: 'Issues created before this date' @@ -33,8 +33,9 @@ module Resolvers argument :closed_after, Types::TimeType, required: false, description: 'Issues closed after this date' - argument :search, GraphQL::STRING_TYPE, # rubocop:disable Graphql/Descriptions - required: false + argument :search, GraphQL::STRING_TYPE, + required: false, + description: 'Search query for finding issues by title or description' argument :sort, Types::IssueSortEnum, description: 'Sort issues by this criteria', required: false, @@ -53,6 +54,7 @@ module Resolvers # https://gitlab.com/gitlab-org/gitlab-foss/issues/54520 args[:project_id] = project.id args[:iids] ||= [args[:iid]].compact + args[:attempt_project_search_optimizations] = args[:search].present? IssuesFinder.new(context[:current_user], args).execute end diff --git a/app/graphql/resolvers/projects/snippets_resolver.rb b/app/graphql/resolvers/projects/snippets_resolver.rb new file mode 100644 index 00000000000..bf9aa45349f --- /dev/null +++ b/app/graphql/resolvers/projects/snippets_resolver.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Resolvers + module Projects + class SnippetsResolver < BaseResolver + include ResolvesSnippets + + alias_method :project, :object + + def resolve(**args) + return Snippet.none if project.nil? + + super + end + + private + + def snippet_finder_params(args) + super.merge(project: project) + end + end + end +end diff --git a/app/graphql/resolvers/snippets_resolver.rb b/app/graphql/resolvers/snippets_resolver.rb new file mode 100644 index 00000000000..530a288a25b --- /dev/null +++ b/app/graphql/resolvers/snippets_resolver.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Resolvers + class SnippetsResolver < BaseResolver + include ResolvesSnippets + + ERROR_MESSAGE = 'Filtering by both an author and a project is not supported' + + alias_method :user, :object + + argument :author_id, GraphQL::ID_TYPE, + required: false, + description: 'The ID of an author' + + argument :project_id, GraphQL::ID_TYPE, + required: false, + description: 'The ID of a project' + + argument :type, Types::Snippets::TypeEnum, + required: false, + description: 'The type of snippet' + + argument :explore, + GraphQL::BOOLEAN_TYPE, + required: false, + description: 'Explore personal snippets' + + def resolve(**args) + if args[:author_id].present? && args[:project_id].present? + raise Gitlab::Graphql::Errors::ArgumentError, ERROR_MESSAGE + end + + super + end + + private + + def snippet_finder_params(args) + super + .merge(author: resolve_gid(args[:author_id], :author), + project: resolve_gid(args[:project_id], :project), + explore: args[:explore]) + end + end +end diff --git a/app/graphql/resolvers/todo_resolver.rb b/app/graphql/resolvers/todo_resolver.rb index 38a4539f34a..cff65321dc0 100644 --- a/app/graphql/resolvers/todo_resolver.rb +++ b/app/graphql/resolvers/todo_resolver.rb @@ -38,53 +38,15 @@ module Resolvers private - # TODO: Support multiple queries for e.g. state and type on TodosFinder: - # - # https://gitlab.com/gitlab-org/gitlab/merge_requests/18487 - # https://gitlab.com/gitlab-org/gitlab/merge_requests/18518 - # - # As soon as these MR's are merged, we can refactor this to query by - # multiple contents. - # def todo_finder_params(args) { - state: first_state(args), - type: first_type(args), - group_id: first_group_id(args), - author_id: first_author_id(args), - action_id: first_action(args), - project_id: first_project(args) + state: args[:state], + type: args[:type], + group_id: args[:group_id], + author_id: args[:author_id], + action_id: args[:action], + project_id: args[:project_id] } end - - def first_project(args) - first_query_field(args, :project_id) - end - - def first_action(args) - first_query_field(args, :action) - end - - def first_author_id(args) - first_query_field(args, :author_id) - end - - def first_group_id(args) - first_query_field(args, :group_id) - end - - def first_state(args) - first_query_field(args, :state) - end - - def first_type(args) - first_query_field(args, :type) - end - - def first_query_field(query, field) - return unless query.key?(field) - - query[field].first if query[field].respond_to?(:first) - end end end diff --git a/app/graphql/resolvers/users/snippets_resolver.rb b/app/graphql/resolvers/users/snippets_resolver.rb new file mode 100644 index 00000000000..d757640b5ff --- /dev/null +++ b/app/graphql/resolvers/users/snippets_resolver.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Resolvers + module Users + class SnippetsResolver < BaseResolver + include ResolvesSnippets + + alias_method :user, :object + + argument :type, Types::Snippets::TypeEnum, + required: false, + description: 'The type of snippet' + + private + + def snippet_finder_params(args) + super.merge(author: user) + end + end + end +end diff --git a/app/graphql/types/diff_refs_type.rb b/app/graphql/types/diff_refs_type.rb index 33a5780cd68..03d080d784b 100644 --- a/app/graphql/types/diff_refs_type.rb +++ b/app/graphql/types/diff_refs_type.rb @@ -6,9 +6,12 @@ module Types class DiffRefsType < BaseObject graphql_name 'DiffRefs' - field :head_sha, GraphQL::STRING_TYPE, null: false, description: 'The sha of the head at the time the comment was made' - field :base_sha, GraphQL::STRING_TYPE, null: false, description: 'The merge base of the branch the comment was made on' - field :start_sha, GraphQL::STRING_TYPE, null: false, description: 'The sha of the branch being compared against' + field :head_sha, GraphQL::STRING_TYPE, null: false, + description: 'SHA of the HEAD at the time the comment was made' + field :base_sha, GraphQL::STRING_TYPE, null: false, + description: 'Merge base of the branch the comment was made on' + field :start_sha, GraphQL::STRING_TYPE, null: false, + description: 'SHA of the branch being compared against' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb new file mode 100644 index 00000000000..c680f387a9a --- /dev/null +++ b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Types + module ErrorTracking + class SentryDetailedErrorType < ::Types::BaseObject + graphql_name 'SentryDetailedError' + + present_using SentryDetailedErrorPresenter + + authorize :read_sentry_issue + + field :id, GraphQL::ID_TYPE, + null: false, + description: "ID (global ID) of the error" + field :sentry_id, GraphQL::STRING_TYPE, + method: :id, + null: false, + description: "ID (Sentry ID) of the error" + field :title, GraphQL::STRING_TYPE, + null: false, + description: "Title of the error" + field :type, GraphQL::STRING_TYPE, + null: false, + description: "Type of the error" + field :user_count, GraphQL::INT_TYPE, + null: false, + description: "Count of users affected by the error" + field :count, GraphQL::INT_TYPE, + null: false, + description: "Count of occurrences" + field :first_seen, Types::TimeType, + null: false, + description: "Timestamp when the error was first seen" + field :last_seen, Types::TimeType, + null: false, + description: "Timestamp when the error was last seen" + field :message, GraphQL::STRING_TYPE, + null: true, + description: "Sentry metadata message of the error" + field :culprit, GraphQL::STRING_TYPE, + null: false, + description: "Culprit of the error" + field :external_url, GraphQL::STRING_TYPE, + null: false, + description: "External URL of the error" + field :sentry_project_id, GraphQL::ID_TYPE, + method: :project_id, + null: false, + description: "ID of the project (Sentry project)" + field :sentry_project_name, GraphQL::STRING_TYPE, + method: :project_name, + null: false, + description: "Name of the project affected by the error" + field :sentry_project_slug, GraphQL::STRING_TYPE, + method: :project_slug, + null: false, + description: "Slug of the project affected by the error" + field :short_id, GraphQL::STRING_TYPE, + null: false, + description: "Short ID (Sentry ID) of the error" + field :status, Types::ErrorTracking::SentryErrorStatusEnum, + null: false, + description: "Status of the error" + field :frequency, [Types::ErrorTracking::SentryErrorFrequencyType], + null: false, + description: "Last 24hr stats of the error" + field :first_release_last_commit, GraphQL::STRING_TYPE, + null: true, + description: "Commit the error was first seen" + field :last_release_last_commit, GraphQL::STRING_TYPE, + null: true, + description: "Commit the error was last seen" + field :first_release_short_version, GraphQL::STRING_TYPE, + null: true, + description: "Release version the error was first seen" + field :last_release_short_version, GraphQL::STRING_TYPE, + null: true, + description: "Release version the error was last seen" + + def first_seen + DateTime.parse(object.first_seen) + end + + def last_seen + DateTime.parse(object.last_seen) + end + + def project_id + Gitlab::GlobalId.build(model_name: 'Project', id: object.project_id).to_s + end + end + end +end diff --git a/app/graphql/types/error_tracking/sentry_error_frequency_type.rb b/app/graphql/types/error_tracking/sentry_error_frequency_type.rb new file mode 100644 index 00000000000..a44ca0684b6 --- /dev/null +++ b/app/graphql/types/error_tracking/sentry_error_frequency_type.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module ErrorTracking + # rubocop: disable Graphql/AuthorizeTypes + class SentryErrorFrequencyType < ::Types::BaseObject + graphql_name 'SentryErrorFrequency' + + field :time, Types::TimeType, + null: false, + description: "Time the error frequency stats were recorded" + field :count, GraphQL::INT_TYPE, + null: false, + description: "Count of errors received since the previously recorded time" + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/error_tracking/sentry_error_status_enum.rb b/app/graphql/types/error_tracking/sentry_error_status_enum.rb new file mode 100644 index 00000000000..df68eef4f3c --- /dev/null +++ b/app/graphql/types/error_tracking/sentry_error_status_enum.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module ErrorTracking + class SentryErrorStatusEnum < ::Types::BaseEnum + graphql_name 'SentryErrorStatus' + description 'State of a Sentry error' + + value 'RESOLVED', value: 'resolved', description: 'Error has been resolved' + value 'RESOLVED_IN_NEXT_RELEASE', value: 'resolvedInNextRelease', description: 'Error has been ignored until next release' + value 'UNRESOLVED', value: 'unresolved', description: 'Error is unresolved' + value 'IGNORED', value: 'ignored', description: 'Error has been ignored' + end + end +end diff --git a/app/graphql/types/issuable_sort_enum.rb b/app/graphql/types/issuable_sort_enum.rb index 932e90c2d22..9fb1249d582 100644 --- a/app/graphql/types/issuable_sort_enum.rb +++ b/app/graphql/types/issuable_sort_enum.rb @@ -1,10 +1,8 @@ # frozen_string_literal: true module Types - # rubocop: disable Graphql/AuthorizeTypes class IssuableSortEnum < SortEnum graphql_name 'IssuableSort' description 'Values for sorting issuables' end - # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/issue_sort_enum.rb b/app/graphql/types/issue_sort_enum.rb index 48ff5819286..c8d8f3ef079 100644 --- a/app/graphql/types/issue_sort_enum.rb +++ b/app/graphql/types/issue_sort_enum.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true module Types - # rubocop: disable Graphql/AuthorizeTypes class IssueSortEnum < IssuableSortEnum graphql_name 'IssueSort' description 'Values for sorting issues' @@ -10,5 +9,6 @@ module Types value 'DUE_DATE_DESC', 'Due date by descending order', value: 'due_date_desc' value 'RELATIVE_POSITION_ASC', 'Relative position by ascending order', value: 'relative_position_asc' end - # rubocop: enable Graphql/AuthorizeTypes end + +Types::IssueSortEnum.prepend_if_ee('::EE::Types::IssueSortEnum') diff --git a/app/graphql/types/issue_state_enum.rb b/app/graphql/types/issue_state_enum.rb index 70c34fbe491..6521407fc9d 100644 --- a/app/graphql/types/issue_state_enum.rb +++ b/app/graphql/types/issue_state_enum.rb @@ -1,11 +1,8 @@ # frozen_string_literal: true module Types - # rubocop: disable Graphql/AuthorizeTypes - # This is a BaseEnum through IssuableEnum, so it does not need authorization class IssueStateEnum < IssuableStateEnum graphql_name 'IssueState' description 'State of a GitLab issue' end - # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/label_type.rb b/app/graphql/types/label_type.rb index d0bcf2068b7..738a00ad616 100644 --- a/app/graphql/types/label_type.rb +++ b/app/graphql/types/label_type.rb @@ -9,7 +9,7 @@ module Types field :id, GraphQL::ID_TYPE, null: false, description: 'Label ID' field :description, GraphQL::STRING_TYPE, null: true, - description: 'Description of the label (markdown rendered as HTML for caching)' + description: 'Description of the label (Markdown rendered as HTML for caching)' markdown_field :description_html, null: true field :title, GraphQL::STRING_TYPE, null: false, description: 'Content of the label' diff --git a/app/graphql/types/merge_request_state_enum.rb b/app/graphql/types/merge_request_state_enum.rb index 37c890a3c8d..92f52726ab3 100644 --- a/app/graphql/types/merge_request_state_enum.rb +++ b/app/graphql/types/merge_request_state_enum.rb @@ -1,13 +1,10 @@ # frozen_string_literal: true module Types - # rubocop: disable Graphql/AuthorizeTypes - # This is a BaseEnum through IssuableEnum, so it does not need authorization class MergeRequestStateEnum < IssuableStateEnum graphql_name 'MergeRequestState' description 'State of a GitLab merge request' value 'merged' end - # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index 278a95fe3ca..0da95b367d8 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -20,7 +20,7 @@ module Types description: 'Title of the merge request' markdown_field :title_html, null: true field :description, GraphQL::STRING_TYPE, null: true, - description: 'Description of the merge request (markdown rendered as HTML for caching)' + description: 'Description of the merge request (Markdown rendered as HTML for caching)' markdown_field :description_html, null: true field :state, MergeRequestStateEnum, null: false, description: 'State of the merge request' diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index b3c7c162bb3..0a9c0143945 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -9,6 +9,8 @@ module Types mount_mutation Mutations::AwardEmojis::Add mount_mutation Mutations::AwardEmojis::Remove mount_mutation Mutations::AwardEmojis::Toggle + mount_mutation Mutations::Issues::SetConfidential + mount_mutation Mutations::Issues::SetDueDate mount_mutation Mutations::MergeRequests::SetLabels mount_mutation Mutations::MergeRequests::SetLocked mount_mutation Mutations::MergeRequests::SetMilestone @@ -21,6 +23,12 @@ module Types mount_mutation Mutations::Notes::Update mount_mutation Mutations::Notes::Destroy mount_mutation Mutations::Todos::MarkDone + mount_mutation Mutations::Todos::Restore + mount_mutation Mutations::Todos::MarkAllDone + mount_mutation Mutations::Snippets::Destroy + mount_mutation Mutations::Snippets::Update + mount_mutation Mutations::Snippets::Create + mount_mutation Mutations::Snippets::MarkAsSpam end end diff --git a/app/graphql/types/notes/diff_position_type.rb b/app/graphql/types/notes/diff_position_type.rb index cab8c750dc0..654562da0a7 100644 --- a/app/graphql/types/notes/diff_position_type.rb +++ b/app/graphql/types/notes/diff_position_type.rb @@ -7,36 +7,38 @@ module Types class DiffPositionType < BaseObject graphql_name 'DiffPosition' - field :diff_refs, Types::DiffRefsType, null: false # rubocop:disable Graphql/Descriptions + field :diff_refs, Types::DiffRefsType, null: false, + description: 'Information about the branch, HEAD, and base at the time of commenting' field :file_path, GraphQL::STRING_TYPE, null: false, - description: "The path of the file that was changed" + description: 'Path of the file that was changed' field :old_path, GraphQL::STRING_TYPE, null: true, - description: "The path of the file on the start sha." + description: 'Path of the file on the start SHA' field :new_path, GraphQL::STRING_TYPE, null: true, - description: "The path of the file on the head sha." - field :position_type, Types::Notes::PositionTypeEnum, null: false # rubocop:disable Graphql/Descriptions + description: 'Path of the file on the HEAD SHA' + field :position_type, Types::Notes::PositionTypeEnum, null: false, + description: 'Type of file the position refers to' # Fields for text positions field :old_line, GraphQL::INT_TYPE, null: true, - description: "The line on start sha that was changed", + description: 'Line on start SHA that was changed', resolve: -> (position, _args, _ctx) { position.old_line if position.on_text? } field :new_line, GraphQL::INT_TYPE, null: true, - description: "The line on head sha that was changed", + description: 'Line on HEAD SHA that was changed', resolve: -> (position, _args, _ctx) { position.new_line if position.on_text? } # Fields for image positions field :x, GraphQL::INT_TYPE, null: true, - description: "The X postion on which the comment was made", + description: 'X position on which the comment was made', resolve: -> (position, _args, _ctx) { position.x if position.on_image? } field :y, GraphQL::INT_TYPE, null: true, - description: "The Y position on which the comment was made", + description: 'Y position on which the comment was made', resolve: -> (position, _args, _ctx) { position.y if position.on_image? } field :width, GraphQL::INT_TYPE, null: true, - description: "The total width of the image", + description: 'Total width of the image', resolve: -> (position, _args, _ctx) { position.width if position.on_image? } field :height, GraphQL::INT_TYPE, null: true, - description: "The total height of the image", + description: 'Total height of the image', resolve: -> (position, _args, _ctx) { position.height if position.on_image? } end # rubocop: enable Graphql/AuthorizeTypes diff --git a/app/graphql/types/notes/discussion_type.rb b/app/graphql/types/notes/discussion_type.rb index ab87f8280ac..74a233e9d26 100644 --- a/app/graphql/types/notes/discussion_type.rb +++ b/app/graphql/types/notes/discussion_type.rb @@ -7,10 +7,14 @@ module Types authorize :read_note - field :id, GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :reply_id, GraphQL::ID_TYPE, null: false, description: 'The ID used to reply to this discussion' - field :created_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions - field :notes, Types::Notes::NoteType.connection_type, null: false, description: "All notes in the discussion" + field :id, GraphQL::ID_TYPE, null: false, + description: "ID of this discussion" + field :reply_id, GraphQL::ID_TYPE, null: false, + description: 'ID used to reply to this discussion' + field :created_at, Types::TimeType, null: false, + description: "Timestamp of the discussion's creation" + field :notes, Types::Notes::NoteType.connection_type, null: false, + description: 'All notes in the discussion' # The gem we use to generate Global IDs is hard-coded to work with # `id` properties. To generate a GID for the `reply_id` property, diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb index 4edf6ed90f7..b60fc96bd03 100644 --- a/app/graphql/types/notes/note_type.rb +++ b/app/graphql/types/notes/note_type.rb @@ -9,40 +9,48 @@ module Types expose_permissions Types::PermissionTypes::Note - field :id, GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the note' field :project, Types::ProjectType, null: true, - description: "The project this note is associated to", + description: 'Project associated with the note', resolve: -> (note, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, note.project_id).find } field :author, Types::UserType, null: false, - description: "The user who wrote this note", + description: 'User who wrote this note', resolve: -> (note, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, note.author_id).find } field :resolved_by, Types::UserType, null: true, - description: "The user that resolved the discussion", + description: 'User that resolved the discussion', resolve: -> (note, _args, _context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, note.resolved_by_id).find } field :system, GraphQL::BOOLEAN_TYPE, null: false, - description: "Whether or not this note was created by the system or by a user" + description: 'Indicates whether this note was created by the system or by a user' field :body, GraphQL::STRING_TYPE, null: false, method: :note, - description: "The content note itself" + description: 'Content of the note' markdown_field :body_html, null: true, method: :note - field :created_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions - field :updated_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions - field :discussion, Types::Notes::DiscussionType, null: true, description: "The discussion this note is a part of" - field :resolvable, GraphQL::BOOLEAN_TYPE, null: false, method: :resolvable? # rubocop:disable Graphql/Descriptions - field :resolved_at, Types::TimeType, null: true, description: "The time the discussion was resolved" - field :position, Types::Notes::DiffPositionType, null: true, description: "The position of this note on a diff" + field :created_at, Types::TimeType, null: false, + description: 'Timestamp of the note creation' + field :updated_at, Types::TimeType, null: false, + description: "Timestamp of the note's last activity" + field :discussion, Types::Notes::DiscussionType, null: true, + description: 'The discussion this note is a part of' + field :resolvable, GraphQL::BOOLEAN_TYPE, null: false, + description: 'Indicates if this note can be resolved. That is, if it is a resolvable discussion or simply a standalone note', + method: :resolvable? + field :resolved_at, Types::TimeType, null: true, + description: "Timestamp of the note's resolution" + field :position, Types::Notes::DiffPositionType, null: true, + description: 'The position of this note on a diff' end end end diff --git a/app/graphql/types/notes/noteable_type.rb b/app/graphql/types/notes/noteable_type.rb index ab4a170b123..2ac66452841 100644 --- a/app/graphql/types/notes/noteable_type.rb +++ b/app/graphql/types/notes/noteable_type.rb @@ -15,6 +15,8 @@ module Types Types::IssueType when MergeRequest Types::MergeRequestType + when Snippet + Types::SnippetType else raise "Unknown GraphQL type for #{object}" end diff --git a/app/graphql/types/permission_types/project.rb b/app/graphql/types/permission_types/project.rb index 3a6ba371154..2879dbd2b5c 100644 --- a/app/graphql/types/permission_types/project.rb +++ b/app/graphql/types/permission_types/project.rb @@ -10,13 +10,19 @@ module Types :remove_pages, :read_project, :create_merge_request_in, :read_wiki, :read_project_member, :create_issue, :upload_file, :read_cycle_analytics, :download_code, :download_wiki_code, - :fork_project, :create_project_snippet, :read_commit_status, + :fork_project, :read_commit_status, :request_access, :create_pipeline, :create_pipeline_schedule, :create_merge_request_from, :create_wiki, :push_code, :create_deployment, :push_to_delete_protected_branch, :admin_wiki, :admin_project, :update_pages, :admin_remote_mirror, :create_label, :update_wiki, :destroy_wiki, :create_pages, :destroy_pages, :read_pages_content, :admin_operations + + permission_field :create_snippet + + def create_snippet + Ability.allowed?(context[:current_user], :create_project_snippet, object) + end end end end diff --git a/app/graphql/types/permission_types/snippet.rb b/app/graphql/types/permission_types/snippet.rb new file mode 100644 index 00000000000..0fc13c60983 --- /dev/null +++ b/app/graphql/types/permission_types/snippet.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module PermissionTypes + class Snippet < BasePermissionType + graphql_name 'SnippetPermissions' + + abilities :create_note, :award_emoji + + permission_field :read_snippet, method: :can_read_snippet? + permission_field :update_snippet, method: :can_update_snippet? + permission_field :admin_snippet, method: :can_admin_snippet? + permission_field :report_snippet, method: :can_report_as_spam? + end + end +end diff --git a/app/graphql/types/permission_types/user.rb b/app/graphql/types/permission_types/user.rb new file mode 100644 index 00000000000..dba4de2dacc --- /dev/null +++ b/app/graphql/types/permission_types/user.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module PermissionTypes + class User < BasePermissionType + graphql_name 'UserPermissions' + + permission_field :create_snippet + + def create_snippet + Ability.allowed?(context[:current_user], :create_personal_snippet) + end + end + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 73255021119..bd80ad7ff74 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -145,5 +145,19 @@ module Types null: true, description: 'Build pipelines of the project', resolver: Resolvers::ProjectPipelinesResolver + + field :sentry_detailed_error, + Types::ErrorTracking::SentryDetailedErrorType, + null: true, + description: 'Detailed version of a Sentry error on the project', + resolver: Resolvers::ErrorTracking::SentryDetailedErrorResolver + + field :snippets, + Types::SnippetType.connection_type, + null: true, + description: 'Snippets of the project', + resolver: Resolvers::Projects::SnippetsResolver end end + +Types::ProjectType.prepend_if_ee('::EE::Types::ProjectType') diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 996bf225976..199a6226c6d 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -29,6 +29,14 @@ module Types resolver: Resolvers::MetadataResolver, description: 'Metadata about GitLab' - field :echo, GraphQL::STRING_TYPE, null: false, resolver: Resolvers::EchoResolver # rubocop:disable Graphql/Descriptions + field :snippets, + Types::SnippetType.connection_type, + null: true, + resolver: Resolvers::SnippetsResolver, + description: 'Find Snippets visible to the current user' + + field :echo, GraphQL::STRING_TYPE, null: false, + description: 'Text to echo back', + resolver: Resolvers::EchoResolver end end diff --git a/app/graphql/types/root_storage_statistics_type.rb b/app/graphql/types/root_storage_statistics_type.rb index a7498ee0a2e..3c471df072d 100644 --- a/app/graphql/types/root_storage_statistics_type.rb +++ b/app/graphql/types/root_storage_statistics_type.rb @@ -7,7 +7,7 @@ module Types authorize :read_statistics field :storage_size, GraphQL::INT_TYPE, null: false, description: 'The total storage in bytes' - field :repository_size, GraphQL::INT_TYPE, null: false, description: 'The git repository size in bytes' + field :repository_size, GraphQL::INT_TYPE, null: false, description: 'The Git repository size in bytes' field :lfs_objects_size, GraphQL::INT_TYPE, null: false, description: 'The LFS objects size in bytes' field :build_artifacts_size, GraphQL::INT_TYPE, null: false, description: 'The CI artifacts size in bytes' field :packages_size, GraphQL::INT_TYPE, null: false, description: 'The packages size in bytes' diff --git a/app/graphql/types/snippet_type.rb b/app/graphql/types/snippet_type.rb new file mode 100644 index 00000000000..3f780528945 --- /dev/null +++ b/app/graphql/types/snippet_type.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Types + class SnippetType < BaseObject + graphql_name 'Snippet' + description 'Represents a snippet entry' + + implements(Types::Notes::NoteableType) + + present_using SnippetPresenter + + authorize :read_snippet + + expose_permissions Types::PermissionTypes::Snippet + + field :id, GraphQL::ID_TYPE, + description: 'Id of the snippet', + null: false + + field :title, GraphQL::STRING_TYPE, + description: 'Title of the snippet', + null: false + + field :project, Types::ProjectType, + description: 'The project the snippet is associated with', + null: true, + authorize: :read_project, + resolve: -> (snippet, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, snippet.project_id).find } + + field :author, Types::UserType, + description: 'The owner of the snippet', + null: false, + resolve: -> (snippet, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, snippet.author_id).find } + + field :file_name, GraphQL::STRING_TYPE, + description: 'File Name of the snippet', + null: true + + field :content, GraphQL::STRING_TYPE, + description: 'Content of the snippet', + null: false + + field :description, GraphQL::STRING_TYPE, + description: 'Description of the snippet', + null: true + + field :visibility_level, Types::VisibilityLevelsEnum, + description: 'Visibility Level of the snippet', + null: false + + field :created_at, Types::TimeType, + description: 'Timestamp this snippet was created', + null: false + + field :updated_at, Types::TimeType, + description: 'Timestamp this snippet was updated', + null: false + + field :web_url, type: GraphQL::STRING_TYPE, + description: 'Web URL of the snippet', + null: false + + field :raw_url, type: GraphQL::STRING_TYPE, + description: 'Raw URL of the snippet', + null: false + + markdown_field :description_html, null: true, method: :description + end +end diff --git a/app/graphql/types/snippets/type_enum.rb b/app/graphql/types/snippets/type_enum.rb new file mode 100644 index 00000000000..243f05359db --- /dev/null +++ b/app/graphql/types/snippets/type_enum.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + module Snippets + class TypeEnum < BaseEnum + value 'personal', value: 'personal' + value 'project', value: 'project' + end + end +end diff --git a/app/graphql/types/snippets/visibility_scopes_enum.rb b/app/graphql/types/snippets/visibility_scopes_enum.rb new file mode 100644 index 00000000000..5488e05b95d --- /dev/null +++ b/app/graphql/types/snippets/visibility_scopes_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Snippets + class VisibilityScopesEnum < BaseEnum + value 'private', value: 'are_private' + value 'internal', value: 'are_internal' + value 'public', value: 'are_public' + end + end +end diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index b45c7893e75..3943c891335 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -8,6 +8,8 @@ module Types present_using UserPresenter + expose_permissions Types::PermissionTypes::User + field :name, GraphQL::STRING_TYPE, null: false, description: 'Human-readable name of the user' field :username, GraphQL::STRING_TYPE, null: false, @@ -19,5 +21,11 @@ module Types field :todos, Types::TodoType.connection_type, null: false, resolver: Resolvers::TodoResolver, description: 'Todos of the user' + + field :snippets, + Types::SnippetType.connection_type, + null: true, + description: 'Snippets authored by the user', + resolver: Resolvers::Users::SnippetsResolver end end diff --git a/app/graphql/types/visibility_levels_enum.rb b/app/graphql/types/visibility_levels_enum.rb new file mode 100644 index 00000000000..d5ace24455e --- /dev/null +++ b/app/graphql/types/visibility_levels_enum.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Types + class VisibilityLevelsEnum < BaseEnum + Gitlab::VisibilityLevel.string_options.each do |name, int_value| + value name.downcase, value: int_value + end + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 3ae804ff231..8389272fd35 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -94,6 +94,25 @@ module ApplicationHelper sanitize(str, tags: %w(a span)) end + def body_data + { + page: body_data_page, + page_type_id: controller.params[:id], + find_file: find_file_path, + group: "#{@group&.path}" + }.merge(project_data) + end + + def project_data + return {} unless @project + + { + project_id: @project.id, + project: @project.path, + namespace_id: @project.namespace&.id + } + end + def body_data_page [*controller.controller_path.split('/'), controller.action_name].compact.join(':') end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index a011209375e..71e4195c50f 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -232,6 +232,7 @@ module ApplicationSettingsHelper :metrics_port, :metrics_sample_interval, :metrics_timeout, + :minimum_password_length, :mirror_available, :pages_domain_verification_enabled, :password_authentication_enabled_for_web, @@ -301,7 +302,8 @@ module ApplicationSettingsHelper :snowplow_iglu_registry_url, :push_event_hooks_limit, :push_event_activities_limit, - :custom_http_clone_url_root + :custom_http_clone_url_root, + :snippet_size_limit ] end diff --git a/app/helpers/award_emoji_helper.rb b/app/helpers/award_emoji_helper.rb index 4bc5a7b090e..13df53a751b 100644 --- a/app/helpers/award_emoji_helper.rb +++ b/app/helpers/award_emoji_helper.rb @@ -7,7 +7,7 @@ module AwardEmojiHelper if awardable.is_a?(Note) # We render a list of notes very frequently and calling the specific method is a lot faster than the generic one (4.5x) if awardable.for_personal_snippet? - toggle_award_emoji_snippet_note_path(awardable.noteable, awardable) + gitlab_toggle_award_emoji_snippet_note_path(awardable.noteable, awardable) else toggle_award_emoji_project_note_path(@project, awardable.id) end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 912f0b61978..c9fb28d0299 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -141,7 +141,7 @@ module BlobHelper if @build && @entry raw_project_job_artifacts_url(@project, @build, path: @entry.path, **kwargs) elsif @snippet - reliable_raw_snippet_url(@snippet) + gitlab_raw_snippet_url(@snippet) elsif @blob project_raw_url(@project, @id, **kwargs) end @@ -215,14 +215,29 @@ module BlobHelper return if blob.binary? || blob.stored_externally? title = _('Open raw') - link_to icon('file-code-o'), blob_raw_path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: title, data: { container: 'body' } + link_to sprite_icon('doc-code'), + external_storage_url_or_path(blob_raw_path), + class: 'btn btn-sm has-tooltip', + target: '_blank', + rel: 'noopener noreferrer', + aria: { label: title }, + title: title, + data: { container: 'body' } end def download_blob_button(blob) return if blob.empty? title = _('Download') - link_to sprite_icon('download'), blob_raw_path(inline: false), download: @path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: title, data: { container: 'body' } + link_to sprite_icon('download'), + external_storage_url_or_path(blob_raw_path(inline: false)), + download: @path, + class: 'btn btn-sm has-tooltip', + target: '_blank', + rel: 'noopener noreferrer', + aria: { label: title }, + title: title, + data: { container: 'body' } end def blob_render_error_reason(viewer) diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb index 495c29d3e24..21e57a8d391 100644 --- a/app/helpers/broadcast_messages_helper.rb +++ b/app/helpers/broadcast_messages_helper.rb @@ -1,11 +1,15 @@ # frozen_string_literal: true module BroadcastMessagesHelper + def current_broadcast_messages + BroadcastMessage.current(request.path) + end + def broadcast_message(message) return unless message.present? content_tag :div, dir: 'auto', class: 'broadcast-message', style: broadcast_message_style(message) do - icon('bullhorn') << ' ' << render_broadcast_message(message) + sprite_icon('bullhorn', size: 16, css_class: 'vertical-align-text-top mr-2') << ' ' << render_broadcast_message(message) end end diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index 0037c49f134..f55acad8517 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -9,11 +9,11 @@ module ClustersHelper def create_new_cluster_label(provider: nil) case provider when 'aws' - s_('ClusterIntegration|Create new Cluster on EKS') + s_('ClusterIntegration|Create new cluster on EKS') when 'gcp' - s_('ClusterIntegration|Create new Cluster on GKE') + s_('ClusterIntegration|Create new cluster on GKE') else - s_('ClusterIntegration|Create new Cluster') + s_('ClusterIntegration|Create new cluster') end end diff --git a/app/helpers/container_expiration_policies_helper.rb b/app/helpers/container_expiration_policies_helper.rb new file mode 100644 index 00000000000..17791e7b0ff --- /dev/null +++ b/app/helpers/container_expiration_policies_helper.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module ContainerExpirationPoliciesHelper + def cadence_options + ContainerExpirationPolicy.cadence_options.map do |key, val| + { key: key.to_s, label: val } + end + end + + def keep_n_options + ContainerExpirationPolicy.keep_n_options.map do |key, val| + { key: key, label: val } + end + end + + def older_than_options + ContainerExpirationPolicy.older_than_options.map do |key, val| + { key: key.to_s, label: val } + end + end +end diff --git a/app/helpers/conversational_development_index_helper.rb b/app/helpers/dev_ops_score_helper.rb index 37e5bb325fb..9a673998149 100644 --- a/app/helpers/conversational_development_index_helper.rb +++ b/app/helpers/dev_ops_score_helper.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ConversationalDevelopmentIndexHelper +module DevOpsScoreHelper def score_level(score) if score < 33.33 'low' diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index 52ec2eadf5e..620a63fdc46 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -161,6 +161,18 @@ module DiffHelper end end + def render_overflow_warning?(diffs_collection) + diff_files = diffs_collection.raw_diff_files + + if diff_files.any?(&:too_large?) + Gitlab::Metrics.add_event(:diffs_overflow_single_file_limits) + end + + diff_files.overflow?.tap do |overflown| + Gitlab::Metrics.add_event(:diffs_overflow_collection_limits) if overflown + end + end + private def diff_btn(title, name, selected) @@ -203,12 +215,6 @@ module DiffHelper link_to "#{hide_whitespace? ? 'Show' : 'Hide'} whitespace changes", url, class: options[:class] end - def render_overflow_warning?(diffs_collection) - diffs = @merge_request_diff.presence || diffs_collection.diff_files - - diffs.overflow? - end - def diff_file_path_text(diff_file, max: 60) path = diff_file.new_path diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index c244eba9e08..ba2330dfc9a 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -112,20 +112,20 @@ module EmailsHelper end end - # "You are receiving this email because #{reason}" + # "You are receiving this email because #{reason} on #{gitlab_host}." def notification_reason_text(reason) - string = case reason - when NotificationReason::OWN_ACTIVITY - 'of your activity' - when NotificationReason::ASSIGNED - 'you have been assigned an item' - when NotificationReason::MENTIONED - 'you have been mentioned' - else - 'of your account' - end - - "#{string} on #{Gitlab.config.gitlab.host}" + gitlab_host = Gitlab.config.gitlab.host + + case reason + when NotificationReason::OWN_ACTIVITY + _("You're receiving this email because of your activity on %{host}.") % { host: gitlab_host } + when NotificationReason::ASSIGNED + _("You're receiving this email because you have been assigned an item on %{host}.") % { host: gitlab_host } + when NotificationReason::MENTIONED + _("You're receiving this email because you have been mentioned on %{host}.") % { host: gitlab_host } + else + _("You're receiving this email because of your account on %{host}.") % { host: gitlab_host } + end end def create_list_id_string(project, list_id_max_length = 255) diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index f57d0fa19d4..59972118ae3 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -26,6 +26,7 @@ module EnvironmentsHelper "empty-getting-started-svg-path" => image_path('illustrations/monitoring/getting_started.svg'), "empty-loading-svg-path" => image_path('illustrations/monitoring/loading.svg'), "empty-no-data-svg-path" => image_path('illustrations/monitoring/no_data.svg'), + "empty-no-data-small-svg-path" => image_path('illustrations/chart-empty-state-small.svg'), "empty-unable-to-connect-svg-path" => image_path('illustrations/monitoring/unable_to_connect.svg'), "metrics-endpoint" => additional_metrics_project_environment_path(project, environment, format: :json), "dashboard-endpoint" => metrics_dashboard_project_environment_path(project, environment, format: :json), diff --git a/app/helpers/git_helper.rb b/app/helpers/git_helper.rb index 5edc6dcf454..0fb37a69e56 100644 --- a/app/helpers/git_helper.rb +++ b/app/helpers/git_helper.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true module GitHelper - def strip_gpg_signature(text) - text.gsub(/-----BEGIN PGP SIGNATURE-----(.*)-----END PGP SIGNATURE-----/m, "") + def strip_signature(text) + text = text.gsub(/-----BEGIN PGP SIGNATURE-----(.*)-----END PGP SIGNATURE-----/m, "") + text = text.gsub(/-----BEGIN PGP MESSAGE-----(.*)-----END PGP MESSAGE-----/m, "") + text = text.gsub(/-----BEGIN SIGNED MESSAGE-----(.*)-----END SIGNED MESSAGE-----/m, "") + text end def short_sha(text) diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index 404ea7b00d4..78c41257404 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -193,6 +193,97 @@ module GitlabRoutingHelper project = schedule.project take_ownership_project_pipeline_schedule_path(project, schedule, *args) end + + def gitlab_snippet_path(snippet, *args) + if snippet.is_a?(ProjectSnippet) + project_snippet_path(snippet.project, snippet, *args) + else + new_args = snippet_query_params(snippet, *args) + snippet_path(snippet, *new_args) + end + end + + def gitlab_snippet_url(snippet, *args) + if snippet.is_a?(ProjectSnippet) + project_snippet_url(snippet.project, snippet, *args) + else + new_args = snippet_query_params(snippet, *args) + snippet_url(snippet, *new_args) + end + end + + def gitlab_raw_snippet_path(snippet, *args) + if snippet.is_a?(ProjectSnippet) + raw_project_snippet_path(snippet.project, snippet, *args) + else + new_args = snippet_query_params(snippet, *args) + raw_snippet_path(snippet, *new_args) + end + end + + def gitlab_raw_snippet_url(snippet, *args) + if snippet.is_a?(ProjectSnippet) + raw_project_snippet_url(snippet.project, snippet, *args) + else + new_args = snippet_query_params(snippet, *args) + raw_snippet_url(snippet, *new_args) + end + end + + def gitlab_snippet_notes_path(snippet, *args) + new_args = snippet_query_params(snippet, *args) + snippet_notes_path(snippet, *new_args) + end + + def gitlab_snippet_notes_url(snippet, *args) + new_args = snippet_query_params(snippet, *args) + snippet_notes_url(snippet, *new_args) + end + + def gitlab_snippet_note_path(snippet, note, *args) + new_args = snippet_query_params(snippet, *args) + snippet_note_path(snippet, note, *new_args) + end + + def gitlab_snippet_note_url(snippet, note, *args) + new_args = snippet_query_params(snippet, *args) + snippet_note_url(snippet, note, *new_args) + end + + def gitlab_toggle_award_emoji_snippet_note_path(snippet, note, *args) + new_args = snippet_query_params(snippet, *args) + toggle_award_emoji_snippet_note_path(snippet, note, *new_args) + end + + def gitlab_toggle_award_emoji_snippet_note_url(snippet, note, *args) + new_args = snippet_query_params(snippet, *args) + toggle_award_emoji_snippet_note_url(snippet, note, *new_args) + end + + def gitlab_toggle_award_emoji_snippet_path(snippet, *args) + new_args = snippet_query_params(snippet, *args) + toggle_award_emoji_snippet_path(snippet, *new_args) + end + + def gitlab_toggle_award_emoji_snippet_url(snippet, *args) + new_args = snippet_query_params(snippet, *args) + toggle_award_emoji_snippet_url(snippet, *new_args) + end + + private + + def snippet_query_params(snippet, *args) + opts = case args.last + when Hash + args.pop + when ActionController::Parameters + args.pop.to_h + else + {} + end + + args << opts + end end GitlabRoutingHelper.include_if_ee('EE::GitlabRoutingHelper') diff --git a/app/helpers/hooks_helper.rb b/app/helpers/hooks_helper.rb index c4b39939192..9466a37ed93 100644 --- a/app/helpers/hooks_helper.rb +++ b/app/helpers/hooks_helper.rb @@ -2,18 +2,40 @@ module HooksHelper def link_to_test_hook(hook, trigger) - path = case hook - when ProjectHook - project = hook.project - test_project_hook_path(project, hook, trigger: trigger) - when SystemHook - test_admin_hook_path(hook, trigger: trigger) - end - + path = test_hook_path(hook, trigger) trigger_human_name = trigger.to_s.tr('_', ' ').camelize link_to path, rel: 'nofollow', method: :post do content_tag(:span, trigger_human_name) end end + + def test_hook_path(hook, trigger) + case hook + when ProjectHook + test_project_hook_path(hook.project, hook, trigger: trigger) + when SystemHook + test_admin_hook_path(hook, trigger: trigger) + end + end + + def edit_hook_path(hook) + case hook + when ProjectHook + edit_project_hook_path(hook.project, hook) + when SystemHook + edit_admin_hook_path(hook) + end + end + + def destroy_hook_path(hook) + case hook + when ProjectHook + project_hook_path(hook.project, hook) + when SystemHook + admin_hook_path(hook) + end + end end + +HooksHelper.prepend_if_ee('EE::HooksHelper') diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index 4f73270577f..876789e0d4a 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -42,11 +42,9 @@ module IconsHelper end def sprite_icon(icon_name, size: nil, css_class: nil) - if Gitlab::Sentry.should_raise_for_dev? - unless known_sprites.include?(icon_name) - exception = ArgumentError.new("#{icon_name} is not a known icon in @gitlab-org/gitlab-svg") - raise exception - end + if known_sprites&.exclude?(icon_name) + exception = ArgumentError.new("#{icon_name} is not a known icon in @gitlab-org/gitlab-svg") + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(exception) end css_classes = [] @@ -158,6 +156,8 @@ module IconsHelper private def known_sprites + return if Rails.env.production? + @known_sprites ||= JSON.parse(File.read(Rails.root.join('node_modules/@gitlab/svgs/dist/icons.json')))['icons'] end end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 3c72f41a4c9..8c75a4a13e8 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -279,19 +279,30 @@ module IssuablesHelper initialDescriptionText: issuable.description, initialTaskStatus: issuable.task_status } + data.merge!(issue_only_initial_data(issuable)) + data.merge!(path_data(parent)) + data.merge!(updated_at_by(issuable)) - data[:hasClosingMergeRequest] = issuable.merge_requests_count(current_user) != 0 if issuable.is_a?(Issue) - data[:zoomMeetingUrl] = ZoomMeeting.canonical_meeting_url(issuable) if issuable.is_a?(Issue) + data + end - if parent.is_a?(Group) - data[:groupPath] = parent.path - else - data.merge!(projectPath: ref_project.path, projectNamespace: ref_project.namespace.full_path) - end + def issue_only_initial_data(issuable) + return {} unless issuable.is_a?(Issue) - data.merge!(updated_at_by(issuable)) + { + hasClosingMergeRequest: issuable.merge_requests_count(current_user) != 0, + zoomMeetingUrl: ZoomMeeting.canonical_meeting_url(issuable), + sentryIssueIdentifier: SentryIssue.find_by(issue: issuable)&.sentry_issue_identifier # rubocop:disable CodeReuse/ActiveRecord + } + end - data + def path_data(parent) + return { groupPath: parent.path } if parent.is_a?(Group) + + { + projectPath: ref_project.path, + projectNamespace: ref_project.namespace.full_path + } end def updated_at_by(issuable) @@ -391,6 +402,10 @@ module IssuablesHelper end end + def issuable_templates_names(issuable) + issuable_templates(issuable).map { |template| template[:name] } + end + def selected_template(issuable) params[:issuable_template] if issuable_templates(issuable).any? { |template| template[:name] == params[:issuable_template] } end diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 6375513f514..34b6ba05a62 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -183,4 +183,4 @@ module IssuesHelper module_function :url_for_tracker_issue end -IssuesHelper.include_if_ee('EE::IssuesHelper') +IssuesHelper.prepend_if_ee('EE::IssuesHelper') diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index 3a872622e73..0d3cf4d73fb 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -47,11 +47,11 @@ module LabelsHelper end end - def render_label(label, tooltip: true, link: nil, css: nil) + def render_label(label, tooltip: true, link: nil, css: nil, dataset: nil) # if scoped label is used then EE wraps label tag with scoped label # doc link html = render_colored_label(label, tooltip: tooltip) - html = link_to(html, link, class: css) if link + html = link_to(html, link, class: css, data: dataset) if link html end diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index b8f6458b499..7940ec1162b 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true module MergeRequestsHelper + include Gitlab::Utils::StrongMemoize + def new_mr_path_from_push_event(event) target_project = event.project.default_merge_request_target project_new_merge_request_path( @@ -27,6 +29,16 @@ module MergeRequestsHelper classes.join(' ') end + def state_name_with_icon(merge_request) + if merge_request.merged? + [_("Merged"), "git-merge"] + elsif merge_request.closed? + [_("Closed"), "close"] + else + [_("Open"), "issue-open-m"] + end + end + def ci_build_details_path(merge_request) build_url = merge_request.source_project.ci_service.build_page(merge_request.diff_head_sha, merge_request.source_branch) return unless build_url @@ -76,7 +88,7 @@ module MergeRequestsHelper def target_projects(project) MergeRequestTargetProjectFinder.new(current_user: current_user, source_project: project) - .execute + .execute(include_routes: true) end def merge_request_button_visibility(merge_request, closed) @@ -158,6 +170,12 @@ module MergeRequestsHelper current_user.fork_of(project) end end + + def mr_tabs_position_enabled? + strong_memoize(:mr_tabs_position_enabled) do + Feature.enabled?(:mr_tabs_position, @project, default_enabled: true) + end + end end MergeRequestsHelper.prepend_if_ee('EE::MergeRequestsHelper') diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 2ce45cec878..6013475acb1 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -87,7 +87,7 @@ module NavHelper end if Feature.enabled?(:user_mode_in_session) - if current_user&.admin? && current_user_mode&.admin_mode? + if current_user_mode.admin_mode? links << :admin_mode end end diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index fbbdebaa623..acf9f8c5b5b 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -95,7 +95,7 @@ module NotesHelper def notes_url(params = {}) if @snippet.is_a?(PersonalSnippet) - snippet_notes_path(@snippet, params) + gitlab_snippet_notes_path(@snippet, params) else params.merge!(target_id: @noteable.id, target_type: @noteable.class.name.underscore) @@ -105,7 +105,7 @@ module NotesHelper def note_url(note, project = @project) if note.noteable.is_a?(PersonalSnippet) - snippet_note_path(note.noteable, note) + gitlab_snippet_note_path(note.noteable, note) else project_note_path(project, note) end @@ -126,7 +126,7 @@ module NotesHelper def new_form_url return unless @snippet.is_a?(PersonalSnippet) - snippet_notes_path(@snippet) + gitlab_snippet_notes_path(@snippet) end def can_create_note? diff --git a/app/helpers/projects/error_tracking_helper.rb b/app/helpers/projects/error_tracking_helper.rb index c31e16e7150..de21a78f5f0 100644 --- a/app/helpers/projects/error_tracking_helper.rb +++ b/app/helpers/projects/error_tracking_helper.rb @@ -18,6 +18,7 @@ module Projects::ErrorTrackingHelper opts = [project, issue_id, { format: :json }] { + 'project-issues-path' => project_issues_path(project), 'issue-details-path' => details_project_error_tracking_index_path(*opts), 'issue-stack-trace-path' => stack_trace_project_error_tracking_index_path(*opts) } diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index c68b6bdea0f..d683faf6a20 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -114,8 +114,10 @@ module ProjectsHelper source = visible_fork_source(project) if source - _('This will remove the fork relationship between this project and %{fork_source}.') % + msg = _('This will remove the fork relationship between this project and %{fork_source}.') % { fork_source: link_to(source.full_name, project_path(source)) } + + msg.html_safe else _('This will remove the fork relationship between this project and other projects in the fork network.') end @@ -195,6 +197,7 @@ module ProjectsHelper "cross-project:#{can?(current_user, :read_cross_project)}", max_project_member_access_cache_key(project), pipeline_status, + Gitlab::I18n.locale, 'v2.6' ] @@ -683,6 +686,7 @@ module ProjectsHelper error_tracking user gcp + logs ] end @@ -696,4 +700,8 @@ module ProjectsHelper def vue_file_list_enabled? Feature.enabled?(:vue_file_list, @project) end + + def show_visibility_confirm_modal?(project) + project.unlink_forks_upon_visibility_decrease_enabled? && project.visibility_level > Gitlab::VisibilityLevel::PRIVATE && project.forks_count > 0 + end end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 777fe82e4c0..a89fea4b7b8 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -31,13 +31,14 @@ module SearchHelper from = collection.offset_value + 1 to = collection.offset_value + collection.to_a.size count = collection.total_count + term_element = "<span> <code>#{h(term)}</code> </span>".html_safe search_entries_info_template(collection) % { from: from, to: to, count: count, scope: search_entries_scope_label(scope, count), - term: term + term_element: term_element } end @@ -72,9 +73,9 @@ module SearchHelper def search_entries_info_template(collection) if collection.total_pages > 1 - s_("SearchResults|Showing %{from} - %{to} of %{count} %{scope} for \"%{term}\"") + s_("SearchResults|Showing %{from} - %{to} of %{count} %{scope} for%{term_element}").html_safe else - s_("SearchResults|Showing %{count} %{scope} for \"%{term}\"") + s_("SearchResults|Showing %{count} %{scope} for%{term_element}").html_safe end end diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb index 19a27ba3499..caef6dba212 100644 --- a/app/helpers/services_helper.rb +++ b/app/helpers/services_helper.rb @@ -31,6 +31,26 @@ module ServicesHelper "#{event}_events" end + def service_event_action_field_name(action) + "#{action}_on_event_enabled" + end + + def event_action_title(action) + case action + when "comment" + s_("ProjectService|Comment") + else + action.humanize + end + end + + def event_action_description(action) + case action + when "comment" + s_("ProjectService|Comment will be posted on each event") + end + end + def service_save_button(service) button_tag(class: 'btn btn-success', type: 'submit', disabled: service.deprecated?, data: { qa_selector: 'save_changes_button' }) do icon('spinner spin', class: 'hidden js-btn-spinner') + diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb index 10e31fb8888..1c7690f30d2 100644 --- a/app/helpers/snippets_helper.rb +++ b/app/helpers/snippets_helper.rb @@ -11,33 +11,9 @@ module SnippetsHelper end end - def reliable_snippet_path(snippet, opts = {}) - reliable_snippet_url(snippet, opts.merge(only_path: true)) - end - - def reliable_raw_snippet_path(snippet, opts = {}) - reliable_raw_snippet_url(snippet, opts.merge(only_path: true)) - end - - def reliable_snippet_url(snippet, opts = {}) - if snippet.project_id? - project_snippet_url(snippet.project, snippet, nil, opts) - else - snippet_url(snippet, nil, opts) - end - end - - def reliable_raw_snippet_url(snippet, opts = {}) - if snippet.project_id? - raw_project_snippet_url(snippet.project, snippet, nil, opts) - else - raw_snippet_url(snippet, nil, opts) - end - end - def download_raw_snippet_button(snippet) link_to(icon('download'), - reliable_raw_snippet_path(snippet, inline: false), + gitlab_raw_snippet_path(snippet, inline: false), target: '_blank', rel: 'noopener noreferrer', class: "btn btn-sm has-tooltip", @@ -133,7 +109,18 @@ module SnippetsHelper end def snippet_embed_tag(snippet) - content_tag(:script, nil, src: reliable_snippet_url(snippet, format: :js, only_path: false)) + content_tag(:script, nil, src: gitlab_snippet_url(snippet, format: :js)) + end + + def snippet_embed_input(snippet) + content_tag(:input, + nil, + type: :text, + readonly: true, + class: 'js-snippet-url-area snippet-embed-input form-control', + data: { url: gitlab_snippet_url(snippet) }, + value: snippet_embed_tag(snippet), + autocomplete: 'off') end def snippet_badge(snippet) @@ -158,7 +145,7 @@ module SnippetsHelper return if blob.empty? || blob.binary? || blob.stored_externally? link_to(external_snippet_icon('doc-code'), - reliable_raw_snippet_url(@snippet), + gitlab_raw_snippet_url(@snippet), class: 'btn', target: '_blank', rel: 'noopener noreferrer', @@ -167,7 +154,7 @@ module SnippetsHelper def embedded_snippet_download_button link_to(external_snippet_icon('download'), - reliable_raw_snippet_url(@snippet, inline: false), + gitlab_raw_snippet_url(@snippet, inline: false), class: 'btn', target: '_blank', title: 'Download', diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index dce0842060d..0211a22a8c4 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -205,4 +205,4 @@ module TodosHelper end end -TodosHelper.prepend_if_ee('EE::NotesHelper'); TodosHelper.prepend_if_ee('EE::TodosHelper') # rubocop: disable Style/Semicolon +TodosHelper.prepend_if_ee('EE::TodosHelper') diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index fc25b78da93..af1919eeb40 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -158,7 +158,9 @@ module TreeHelper def breadcrumb_data_attributes attrs = { can_collaborate: can_collaborate_with_project?(@project).to_s, - new_blob_path: project_new_blob_path(@project, @id), + new_blob_path: project_new_blob_path(@project, @ref), + upload_path: project_create_blob_path(@project, @ref), + new_dir_path: project_create_dir_path(@project, @ref), new_branch_path: new_project_branch_path(@project), new_tag_path: new_project_tag_path(@project), can_edit_tree: can_edit_tree?.to_s diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb index cae3ec5f8d0..11b78b8fd59 100644 --- a/app/helpers/user_callouts_helper.rb +++ b/app/helpers/user_callouts_helper.rb @@ -4,6 +4,7 @@ module UserCalloutsHelper GKE_CLUSTER_INTEGRATION = 'gke_cluster_integration' GCP_SIGNUP_OFFER = 'gcp_signup_offer' SUGGEST_POPOVER_DISMISSED = 'suggest_popover_dismissed' + TABS_POSITION_HIGHLIGHT = 'tabs_position_highlight' def show_gke_cluster_integration_callout?(project) can?(current_user, :create_cluster, project) && @@ -25,6 +26,10 @@ module UserCalloutsHelper !user_dismissed?(SUGGEST_POPOVER_DISMISSED) end + def show_tabs_feature_highlight? + !user_dismissed?(TABS_POSITION_HIGHLIGHT) && !Rails.env.test? + end + private def user_dismissed?(feature_name) diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index ef0cb8b4bcb..e87bb27cf62 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -44,6 +44,14 @@ module UsersHelper current_user_menu_items.include?(item) end + # Used to preload when you are rendering many projects and checking access + # + # rubocop: disable CodeReuse/ActiveRecord: `projects` can be array which also responds to pluck + def load_max_project_member_accesses(projects) + current_user&.max_member_access_for_project_ids(projects.pluck(:id)) + end + # rubocop: enable CodeReuse/ActiveRecord + def max_project_member_access(project) current_user&.max_member_access_for_project(project.id) || Gitlab::Access::NO_ACCESS end @@ -57,7 +65,7 @@ module UsersHelper unless user.association(:status).loaded? exception = RuntimeError.new("Status was not preloaded") - Gitlab::Sentry.track_exception(exception, extra: { user: user.inspect }) + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(exception, user: user.inspect) end return unless user.status diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb index a1c8c3455b5..de70d0073b3 100644 --- a/app/mailers/emails/notes.rb +++ b/app/mailers/emails/notes.rb @@ -38,7 +38,7 @@ module Emails setup_note_mail(note_id, recipient_id) @snippet = @note.noteable - @target_url = snippet_url(@note.noteable) + @target_url = gitlab_snippet_url(@note.noteable) mail_answer_note_thread(@snippet, @note, note_thread_options(recipient_id, reason)) end diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index 2ea1aea1f51..441439444d5 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -32,5 +32,19 @@ module Emails mail(to: @user.notification_email, subject: subject("GPG key was added to your account")) end # rubocop: enable CodeReuse/ActiveRecord + + def access_token_about_to_expire_email(user) + return unless user + + @user = user + @target_url = profile_personal_access_tokens_url + @days_to_expire = PersonalAccessToken::DAYS_TO_EXPIRE + + Gitlab::I18n.with_locale(@user.preferred_language) do + mail(to: @user.notification_email, subject: subject(_("Your Personal Access Tokens will expire in %{days_to_expire} days or less") % { days_to_expire: @days_to_expire })) + end + end end end + +Emails::Profile.prepend_if_ee('EE::Emails::Profile') diff --git a/app/models/active_session.rb b/app/models/active_session.rb index 00192b1da59..3ecc3137157 100644 --- a/app/models/active_session.rb +++ b/app/models/active_session.rb @@ -4,6 +4,7 @@ class ActiveSession include ActiveModel::Model SESSION_BATCH_SIZE = 200 + ALLOWED_NUMBER_OF_ACTIVE_SESSIONS = 100 attr_accessor :created_at, :updated_at, :session_id, :ip_address, @@ -65,21 +66,22 @@ class ActiveSession def self.destroy(user, session_id) Gitlab::Redis::SharedState.with do |redis| - redis.srem(lookup_key_name(user.id), session_id) + destroy_sessions(redis, user, [session_id]) + end + end - deleted_keys = redis.del(key_name(user.id, session_id)) + def self.destroy_sessions(redis, user, session_ids) + key_names = session_ids.map {|session_id| key_name(user.id, session_id) } + session_names = session_ids.map {|session_id| "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}" } - # only allow deleting the devise session if we could actually find a - # related active session. this prevents another user from deleting - # someone else's session. - if deleted_keys > 0 - redis.del("#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}") - end - end + redis.srem(lookup_key_name(user.id), session_ids) + redis.del(key_names) + redis.del(session_names) end def self.cleanup(user) Gitlab::Redis::SharedState.with do |redis| + clean_up_old_sessions(redis, user) cleaned_up_lookup_entries(redis, user) end end @@ -118,19 +120,40 @@ class ActiveSession end end - def self.raw_active_session_entries(session_ids, user_id) + def self.raw_active_session_entries(redis, session_ids, user_id) return [] if session_ids.empty? - Gitlab::Redis::SharedState.with do |redis| - entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) } + entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) } + + redis.mget(entry_keys) + end - redis.mget(entry_keys) + def self.active_session_entries(session_ids, user_id, redis) + return [] if session_ids.empty? + + entry_keys = raw_active_session_entries(redis, session_ids, user_id) + + entry_keys.compact.map do |raw_session| + Marshal.load(raw_session) # rubocop:disable Security/MarshalLoad end end + def self.clean_up_old_sessions(redis, user) + session_ids = session_ids_for_user(user.id) + + return if session_ids.count <= ALLOWED_NUMBER_OF_ACTIVE_SESSIONS + + # remove sessions if there are more than ALLOWED_NUMBER_OF_ACTIVE_SESSIONS. + sessions = active_session_entries(session_ids, user.id, redis) + sessions.sort_by! {|session| session.updated_at }.reverse! + sessions = sessions.drop(ALLOWED_NUMBER_OF_ACTIVE_SESSIONS) + sessions = sessions.map { |session| session.session_id } + destroy_sessions(redis, user, sessions) if sessions.any? + end + def self.cleaned_up_lookup_entries(redis, user) session_ids = session_ids_for_user(user.id) - entries = raw_active_session_entries(session_ids, user.id) + entries = raw_active_session_entries(redis, session_ids, user.id) # remove expired keys. # only the single key entries are automatically expired by redis, the diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 72605af433f..456b6430088 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -6,12 +6,6 @@ class ApplicationSetting < ApplicationRecord include TokenAuthenticatable include ChronicDurationAttribute - # Only remove this >= %12.6 and >= 2019-12-01 - self.ignored_columns += %i[ - pendo_enabled - pendo_url - ] - add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption, default_enabled: true) ? :optional : :required } add_authentication_token_field :health_check_access_token add_authentication_token_field :static_objects_external_storage_auth_token @@ -52,6 +46,12 @@ class ApplicationSetting < ApplicationRecord presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :minimum_password_length, + presence: true, + numericality: { only_integer: true, + greater_than_or_equal_to: DEFAULT_MINIMUM_PASSWORD_LENGTH, + less_than_or_equal_to: Devise.password_length.max } + validates :home_page_url, allow_blank: true, addressable_url: true, @@ -229,6 +229,8 @@ class ApplicationSetting < ApplicationRecord validates :push_event_activities_limit, numericality: { greater_than_or_equal_to: 0 } + validates :snippet_size_limit, numericality: { only_integer: true, greater_than: 0 } + SUPPORTED_KEY_TYPES.each do |type| validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type } end diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 7bb89f0d1e2..98d8bb43b93 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -26,9 +26,12 @@ module ApplicationSettingImplementation '/users', '/users/confirmation', '/unsubscribes/', - '/import/github/personal_access_token' + '/import/github/personal_access_token', + '/admin/session' ].freeze + DEFAULT_MINIMUM_PASSWORD_LENGTH = 8 + class_methods do def defaults { @@ -105,6 +108,7 @@ module ApplicationSettingImplementation sourcegraph_enabled: false, sourcegraph_url: nil, sourcegraph_public_only: true, + minimum_password_length: DEFAULT_MINIMUM_PASSWORD_LENGTH, terminal_max_session_time: 0, throttle_authenticated_api_enabled: false, throttle_authenticated_api_period_in_seconds: 3600, @@ -139,7 +143,8 @@ module ApplicationSettingImplementation snowplow_app_id: nil, snowplow_iglu_registry_url: nil, custom_http_clone_url_root: nil, - productivity_analytics_start_date: Time.now + productivity_analytics_start_date: Time.now, + snippet_size_limit: 50.megabytes } end diff --git a/app/models/badge.rb b/app/models/badge.rb index 50299cd6652..eb351425e66 100644 --- a/app/models/badge.rb +++ b/app/models/badge.rb @@ -22,6 +22,8 @@ class Badge < ApplicationRecord scope :order_created_at_asc, -> { reorder(created_at: :asc) } + scope :with_name, ->(name) { where(name: name) } + validates :link_url, :image_url, addressable_url: true validates :type, presence: true diff --git a/app/models/blob.rb b/app/models/blob.rb index cc089715b06..0a425f2b961 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -4,6 +4,7 @@ class Blob < SimpleDelegator include Presentable include BlobLanguageFromGitAttributes + include BlobActiveModel CACHE_TIME = 60 # Cache raw blobs referred to by a (mutable) ref for 1 minute CACHE_TIME_IMMUTABLE = 3600 # Cache blobs referred to by an immutable reference for 1 hour @@ -26,6 +27,7 @@ class Blob < SimpleDelegator BlobViewer::Markup, BlobViewer::Notebook, BlobViewer::SVG, + BlobViewer::OpenApi, BlobViewer::Image, BlobViewer::Sketch, diff --git a/app/models/blob_viewer/open_api.rb b/app/models/blob_viewer/open_api.rb new file mode 100644 index 00000000000..963b7336c8d --- /dev/null +++ b/app/models/blob_viewer/open_api.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module BlobViewer + class OpenApi < Base + include Rich + include ClientSide + + self.partial_name = 'openapi' + self.file_types = %i(openapi) + self.binary = false + # TODO: get an icon for OpenAPI + self.switcher_icon = 'file-pdf-o' + self.switcher_title = 'OpenAPI' + end +end diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb index dfcf28763ee..b3d72ebdcf3 100644 --- a/app/models/broadcast_message.rb +++ b/app/models/broadcast_message.rb @@ -9,6 +9,7 @@ class BroadcastMessage < ApplicationRecord validates :message, presence: true validates :starts_at, presence: true validates :ends_at, presence: true + validates :broadcast_type, presence: true validates :color, allow_blank: true, color: true validates :font, allow_blank: true, color: true @@ -17,35 +18,62 @@ class BroadcastMessage < ApplicationRecord default_value_for :font, '#FFFFFF' CACHE_KEY = 'broadcast_message_current_json' + BANNER_CACHE_KEY = 'broadcast_message_current_banner_json' + NOTIFICATION_CACHE_KEY = 'broadcast_message_current_notification_json' after_commit :flush_redis_cache - def self.current - messages = cache.fetch(CACHE_KEY, as: BroadcastMessage, expires_in: cache_expires_in) do - current_and_future_messages + enum broadcast_type: { + banner: 1, + notification: 2 + } + + class << self + def current_banner_messages(current_path = nil) + fetch_messages BANNER_CACHE_KEY, current_path do + current_and_future_messages.banner + end end - return [] unless messages&.present? + def current_notification_messages(current_path = nil) + fetch_messages NOTIFICATION_CACHE_KEY, current_path do + current_and_future_messages.notification + end + end - now_or_future = messages.select(&:now_or_future?) + def current(current_path = nil) + fetch_messages CACHE_KEY, current_path do + current_and_future_messages + end + end - # If there are cached entries but none are to be displayed we'll purge the - # cache so we don't keep running this code all the time. - cache.expire(CACHE_KEY) if now_or_future.empty? + def current_and_future_messages + where('ends_at > :now', now: Time.current).order_id_asc + end - now_or_future.select(&:now?) - end + def cache + Gitlab::JsonCache.new(cache_key_with_version: false) + end - def self.current_and_future_messages - where('ends_at > :now', now: Time.zone.now).order_id_asc - end + def cache_expires_in + 2.weeks + end - def self.cache - Gitlab::JsonCache.new(cache_key_with_version: false) - end + private - def self.cache_expires_in - 2.weeks + def fetch_messages(cache_key, current_path) + messages = cache.fetch(cache_key, as: BroadcastMessage, expires_in: cache_expires_in) do + yield + end + + now_or_future = messages.select(&:now_or_future?) + + # If there are cached entries but none are to be displayed we'll purge the + # cache so we don't keep running this code all the time. + cache.expire(cache_key) if now_or_future.empty? + + now_or_future.select(&:now?).select { |message| message.matches_current_path(current_path) } + end end def active? @@ -53,27 +81,35 @@ class BroadcastMessage < ApplicationRecord end def started? - Time.zone.now >= starts_at + Time.current >= starts_at end def ended? - ends_at < Time.zone.now + ends_at < Time.current end def now? - (starts_at..ends_at).cover?(Time.zone.now) + (starts_at..ends_at).cover?(Time.current) end def future? - starts_at > Time.zone.now + starts_at > Time.current end def now_or_future? now? || future? end + def matches_current_path(current_path) + return true if current_path.blank? || target_path.blank? + + current_path.match(Regexp.escape(target_path).gsub('\\*', '.*')) + end + def flush_redis_cache - self.class.cache.expire(CACHE_KEY) + [CACHE_KEY, BANNER_CACHE_KEY, NOTIFICATION_CACHE_KEY].each do |key| + self.class.cache.expire(key) + end end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 49c5b67d600..7e7c580a48e 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -13,17 +13,11 @@ module Ci include Importable include Gitlab::Utils::StrongMemoize include HasRef + include IgnorableColumns BuildArchivedError = Class.new(StandardError) - self.ignored_columns += %i[ - artifacts_file - artifacts_file_store - artifacts_metadata - artifacts_metadata_store - artifacts_size - commands - ] + ignore_columns :artifacts_file, :artifacts_file_store, :artifacts_metadata, :artifacts_metadata_store, :artifacts_size, :commands, remove_after: '2019-12-15', remove_with: '12.7' belongs_to :project, inverse_of: :builds belongs_to :runner @@ -120,6 +114,20 @@ module Ci scope :eager_load_job_artifacts, -> { includes(:job_artifacts) } + scope :eager_load_everything, -> do + includes( + [ + { pipeline: [:project, :user] }, + :job_artifacts_archive, + :metadata, + :trigger_request, + :project, + :user, + :tags + ] + ) + end + scope :with_exposed_artifacts, -> do joins(:metadata).merge(Ci::BuildMetadata.with_exposed_artifacts) .includes(:metadata, :job_artifacts_metadata) @@ -161,6 +169,7 @@ module Ci end scope :queued_before, ->(time) { where(arel_table[:queued_at].lt(time)) } + scope :order_id_desc, -> { order('ci_builds.id DESC') } acts_as_taggable @@ -247,10 +256,11 @@ module Ci end after_transition pending: :running do |build| - build.pipeline.persistent_ref.create build.deployment&.run build.run_after_commit do + build.pipeline.persistent_ref.create + BuildHooksWorker.perform_async(id) end end @@ -277,7 +287,7 @@ module Ci begin build.deployment.drop! rescue => e - Gitlab::Sentry.track_exception(e, extra: { build_id: build.id }) + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e, build_id: build.id) end true @@ -415,6 +425,18 @@ module Ci end end + def expanded_kubernetes_namespace + return unless has_environment? + + namespace = options.dig(:environment, :kubernetes, :namespace) + + if namespace.present? + strong_memoize(:expanded_kubernetes_namespace) do + ExpandVariables.expand(namespace, -> { simple_variables }) + end + end + end + def has_environment? environment.present? end @@ -640,9 +662,8 @@ module Ci def execute_hooks return unless project - build_data = Gitlab::DataBuilder::Build.build(self) - project.execute_hooks(build_data.dup, :job_hooks) - project.execute_services(build_data.dup, :job_hooks) + project.execute_hooks(build_data.dup, :job_hooks) if project.has_active_hooks?(:job_hooks) + project.execute_services(build_data.dup, :job_hooks) if project.has_active_services?(:job_hooks) end def browsable_artifacts? @@ -741,6 +762,10 @@ module Ci Gitlab::Ci::Build::Credentials::Factory.new(self).create! end + def all_dependencies + (dependencies + cross_dependencies).uniq + end + def dependencies return [] if empty_dependencies? @@ -748,7 +773,7 @@ module Ci # find all jobs that are needed if Feature.enabled?(:ci_dag_support, project, default_enabled: true) && needs.exists? - depended_jobs = depended_jobs.where(name: needs.select(:name)) + depended_jobs = depended_jobs.where(name: needs.artifacts.select(:name)) end # find all jobs that are dependent on @@ -756,9 +781,15 @@ module Ci depended_jobs = depended_jobs.where(name: options[:dependencies]) end + # if both needs and dependencies are used, + # the end result will be an intersection between them depended_jobs end + def cross_dependencies + [] + end + def empty_dependencies? options[:dependencies]&.empty? end @@ -849,6 +880,10 @@ module Ci private + def build_data + @build_data ||= Gitlab::DataBuilder::Build.build(self) + end + def successful_deployment_status if deployment&.last? :last @@ -860,7 +895,7 @@ module Ci def each_report(report_types) job_artifacts_for_types(report_types).each do |report_artifact| report_artifact.each_blob do |blob| - yield report_artifact.file_type, blob + yield report_artifact.file_type, blob, report_artifact end end end diff --git a/app/models/ci/build_need.rb b/app/models/ci/build_need.rb index 6531dfd332f..0b243c20e67 100644 --- a/app/models/ci/build_need.rb +++ b/app/models/ci/build_need.rb @@ -10,5 +10,6 @@ module Ci validates :name, presence: true, length: { maximum: 128 } scope :scoped_build, -> { where('ci_builds.id=ci_build_needs.build_id') } + scope :artifacts, -> { where(artifacts: true) } end end diff --git a/app/models/ci/build_trace_section.rb b/app/models/ci/build_trace_section.rb index 7fe6b753da1..8be42eb48d6 100644 --- a/app/models/ci/build_trace_section.rb +++ b/app/models/ci/build_trace_section.rb @@ -4,9 +4,6 @@ module Ci class BuildTraceSection < ApplicationRecord extend Gitlab::Ci::Model - # Only remove > 2019-11-22 and > 12.5 - self.ignored_columns += %i[id] - belongs_to :build, class_name: 'Ci::Build' belongs_to :project belongs_to :section_name, class_name: 'Ci::BuildTraceSectionName' diff --git a/app/models/ci/legacy_stage.rb b/app/models/ci/legacy_stage.rb index 2fd369c9aff..0a67a652e22 100644 --- a/app/models/ci/legacy_stage.rb +++ b/app/models/ci/legacy_stage.rb @@ -5,6 +5,7 @@ module Ci # We should migrate this object to actual database record in the future class LegacyStage include StaticModel + include Presentable attr_reader :pipeline, :name diff --git a/app/models/ci/persistent_ref.rb b/app/models/ci/persistent_ref.rb index be3d4aa3203..76139f5d676 100644 --- a/app/models/ci/persistent_ref.rb +++ b/app/models/ci/persistent_ref.rb @@ -14,31 +14,41 @@ module Ci delegate :ref_exists?, :create_ref, :delete_refs, to: :repository def exist? + return unless enabled? + ref_exists?(path) rescue false end def create - return if exist? + return unless enabled? create_ref(sha, path) rescue => e - Gitlab::Sentry - .track_acceptable_exception(e, extra: { pipeline_id: pipeline.id }) + Gitlab::ErrorTracking + .track_exception(e, pipeline_id: pipeline.id) end def delete + return unless enabled? + delete_refs(path) rescue Gitlab::Git::Repository::NoRepository # no-op rescue => e - Gitlab::Sentry - .track_acceptable_exception(e, extra: { pipeline_id: pipeline.id }) + Gitlab::ErrorTracking + .track_exception(e, pipeline_id: pipeline.id) end def path "refs/#{Repository::REF_PIPELINES}/#{pipeline.id}" end + + private + + def enabled? + Feature.enabled?(:depend_on_persistent_pipeline_ref, project, default_enabled: true) + end end end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index f730b949ee9..29ec41ef1a1 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -14,6 +14,7 @@ module Ci include HasRef include ShaAttribute include FromUnion + include UpdatedAtFilterable sha_attribute :source_sha sha_attribute :target_sha @@ -204,15 +205,7 @@ module Ci end scope :internal, -> { where(source: internal_sources) } - scope :ci_sources, -> { where(config_source: ci_sources_values) } - - scope :sort_by_merge_request_pipelines, -> do - sql = 'CASE ci_pipelines.source WHEN (?) THEN 0 ELSE 1 END, ci_pipelines.id DESC' - query = ApplicationRecord.send(:sanitize_sql_array, [sql, sources[:merge_request_event]]) # rubocop:disable GitlabSecurity/PublicSend - - order(Arel.sql(query)) - end - + scope :ci_sources, -> { where(config_source: ::Ci::PipelineEnums.ci_config_sources_values) } scope :for_user, -> (user) { where(user: user) } scope :for_sha, -> (sha) { where(sha: sha) } scope :for_source_sha, -> (source_sha) { where(source_sha: source_sha) } @@ -221,22 +214,6 @@ module Ci scope :for_id, -> (id) { where(id: id) } scope :created_after, -> (time) { where('ci_pipelines.created_at > ?', time) } - scope :triggered_by_merge_request, -> (merge_request) do - where(source: :merge_request_event, merge_request: merge_request) - end - - scope :detached_merge_request_pipelines, -> (merge_request, sha) do - triggered_by_merge_request(merge_request).for_sha(sha) - end - - scope :merge_request_pipelines, -> (merge_request, source_sha) do - triggered_by_merge_request(merge_request).for_source_sha(source_sha) - end - - scope :triggered_for_branch, -> (ref) do - where(source: branch_pipeline_sources).where(ref: ref, tag: false) - end - scope :with_reports, -> (reports_scope) do where('EXISTS (?)', ::Ci::Build.latest.with_reports(reports_scope).where('ci_pipelines.id=ci_builds.commit_id').select(1)) end @@ -323,11 +300,6 @@ module Ci end end - def self.latest_for_shas(shas) - max_id_per_sha = for_sha(shas).group(:sha).select("max(id)") - where(id: max_id_per_sha) - end - def self.latest_successful_ids_per_project success.group(:project_id).select('max(id) as id') end @@ -344,14 +316,6 @@ module Ci sources.reject { |source| source == "external" }.values end - def self.branch_pipeline_sources - @branch_pipeline_sources ||= sources.reject { |source| source == 'merge_request_event' }.values - end - - def self.ci_sources_values - config_sources.values_at(:repository_source, :auto_devops_source, :unknown_source) - end - def self.bridgeable_statuses ::Ci::Pipeline::AVAILABLE_STATUSES - %w[created preparing pending] end @@ -478,6 +442,10 @@ module Ci end end + def before_sha + super || Gitlab::Git::BLANK_SHA + end + def short_sha Ci::Pipeline.truncate_sha(sha) end @@ -534,6 +502,10 @@ module Ci builds.skipped.after_stage(stage_idx).find_each(&:process) end + def child? + false + end + def latest? return false unless git_ref && commit.present? @@ -599,12 +571,6 @@ module Ci project.notes.for_commit_id(sha) end - # rubocop: disable CodeReuse/ServiceClass - def process!(trigger_build_ids = nil) - Ci::ProcessPipelineService.new(project, user).execute(self, trigger_build_ids) - end - # rubocop: enable CodeReuse/ServiceClass - def update_status retry_optimistic_lock(self) do new_status = latest_builds_status.to_s @@ -646,12 +612,11 @@ module Ci def predefined_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| variables.append(key: 'CI_PIPELINE_IID', value: iid.to_s) - variables.append(key: 'CI_CONFIG_PATH', value: config_path) variables.append(key: 'CI_PIPELINE_SOURCE', value: source.to_s) - variables.append(key: 'CI_COMMIT_MESSAGE', value: git_commit_message.to_s) - variables.append(key: 'CI_COMMIT_TITLE', value: git_commit_full_title.to_s) - variables.append(key: 'CI_COMMIT_DESCRIPTION', value: git_commit_description.to_s) - variables.append(key: 'CI_COMMIT_REF_PROTECTED', value: (!!protected_ref?).to_s) + + variables.append(key: 'CI_CONFIG_PATH', value: config_path) + + variables.concat(predefined_commit_variables) if merge_request_event? && merge_request variables.append(key: 'CI_MERGE_REQUEST_EVENT_TYPE', value: merge_request_event_type.to_s) @@ -666,6 +631,29 @@ module Ci end end + def predefined_commit_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'CI_COMMIT_SHA', value: sha) + variables.append(key: 'CI_COMMIT_SHORT_SHA', value: short_sha) + variables.append(key: 'CI_COMMIT_BEFORE_SHA', value: before_sha) + variables.append(key: 'CI_COMMIT_REF_NAME', value: source_ref) + variables.append(key: 'CI_COMMIT_REF_SLUG', value: source_ref_slug) + variables.append(key: 'CI_COMMIT_BRANCH', value: ref) if branch? + variables.append(key: 'CI_COMMIT_TAG', value: ref) if tag? + variables.append(key: 'CI_COMMIT_MESSAGE', value: git_commit_message.to_s) + variables.append(key: 'CI_COMMIT_TITLE', value: git_commit_full_title.to_s) + variables.append(key: 'CI_COMMIT_DESCRIPTION', value: git_commit_description.to_s) + variables.append(key: 'CI_COMMIT_REF_PROTECTED', value: (!!protected_ref?).to_s) + + # legacy variables + variables.append(key: 'CI_BUILD_REF', value: sha) + variables.append(key: 'CI_BUILD_BEFORE_SHA', value: before_sha) + variables.append(key: 'CI_BUILD_REF_NAME', value: source_ref) + variables.append(key: 'CI_BUILD_REF_SLUG', value: source_ref_slug) + variables.append(key: 'CI_BUILD_TAG', value: ref) if tag? + end + end + def queued_duration return unless started_at @@ -781,18 +769,10 @@ module Ci triggered_by_merge_request? && target_sha.present? end - def merge_train_pipeline? - merge_request_pipeline? && merge_train_ref? - end - def merge_request_ref? MergeRequest.merge_request_ref?(ref) end - def merge_train_ref? - MergeRequest.merge_train_ref?(ref) - end - def matches_sha_or_source_sha?(sha) self.sha == sha || self.source_sha == sha end @@ -825,9 +805,7 @@ module Ci return unless merge_request_event? strong_memoize(:merge_request_event_type) do - if merge_train_pipeline? - :merge_train - elsif merge_request_pipeline? + if merge_request_pipeline? :merged_result elsif detached_merge_request_pipeline? :detached @@ -839,6 +817,10 @@ module Ci @persistent_ref ||= PersistentRef.new(pipeline: self) end + def find_successful_build_ids_by_names(names) + statuses.latest.success.where(name: names).pluck(:id) + end + private def pipeline_data diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb index 859abc4a0d5..3cd88807969 100644 --- a/app/models/ci/pipeline_enums.rb +++ b/app/models/ci/pipeline_enums.rb @@ -7,7 +7,8 @@ module Ci def self.failure_reasons { unknown_failure: 0, - config_error: 1 + config_error: 1, + external_validation_failure: 2 } end @@ -35,9 +36,20 @@ module Ci { unknown_source: nil, repository_source: 1, - auto_devops_source: 2 + auto_devops_source: 2, + remote_source: 4, + external_project_source: 5 } end + + def self.ci_config_sources_values + config_sources.values_at( + :unknown_source, + :repository_source, + :auto_devops_source, + :remote_source, + :external_project_source) + end end end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index c4a4410e8fc..3f409b8bb22 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -8,6 +8,7 @@ module Ci include ChronicDurationAttribute include FromUnion include TokenAuthenticatable + include IgnorableColumns add_authentication_token_field :token, encrypted: -> { Feature.enabled?(:ci_runners_tokens_optional_encryption, default_enabled: true) ? :optional : :required } @@ -35,7 +36,7 @@ module Ci FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze - self.ignored_columns += %i[is_shared] + ignore_column :is_shared, remove_after: '2019-12-15', remove_with: '12.6' has_many :builds has_many :runner_projects, inverse_of: :runner, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent diff --git a/app/models/clusters/applications/elastic_stack.rb b/app/models/clusters/applications/elastic_stack.rb index 8589f8c00cb..9854ad2ea3e 100644 --- a/app/models/clusters/applications/elastic_stack.rb +++ b/app/models/clusters/applications/elastic_stack.rb @@ -71,6 +71,8 @@ module Clusters # `proxy_url` could raise an exception because gitlab can not communicate with the cluster. # We check for a nil client in downstream use and behaviour is equivalent to an empty state log_exception(error, :failed_to_create_elasticsearch_client) + + nil end end diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb index 1093efee85a..387503bee54 100644 --- a/app/models/clusters/applications/knative.rb +++ b/app/models/clusters/applications/knative.rb @@ -3,25 +3,27 @@ module Clusters module Applications class Knative < ApplicationRecord - VERSION = '0.7.0' + VERSION = '0.9.0' REPOSITORY = 'https://storage.googleapis.com/triggermesh-charts' METRICS_CONFIG = 'https://storage.googleapis.com/triggermesh-charts/istio-metrics.yaml' FETCH_IP_ADDRESS_DELAY = 30.seconds - API_RESOURCES_PATH = 'config/knative/api_resources.yml' + API_GROUPS_PATH = 'config/knative/api_groups.yml' self.table_name = 'clusters_applications_knative' + has_one :serverless_domain_cluster, class_name: 'Serverless::DomainCluster', foreign_key: 'clusters_applications_knative_id', inverse_of: :knative + include ::Clusters::Concerns::ApplicationCore include ::Clusters::Concerns::ApplicationStatus include ::Clusters::Concerns::ApplicationVersion include ::Clusters::Concerns::ApplicationData include AfterCommitQueue + alias_method :original_set_initial_status, :set_initial_status def set_initial_status - return unless not_installable? - return unless verify_cluster? + return unless cluster&.platform_kubernetes_rbac? - self.status = status_states[:installable] + original_set_initial_status end state_machine :status do @@ -109,15 +111,15 @@ module Clusters end def delete_knative_and_istio_crds - api_resources.map do |crd| - Gitlab::Kubernetes::KubectlCmd.delete("--ignore-not-found", "crd", "#{crd}") + api_groups.map do |group| + Gitlab::Kubernetes::KubectlCmd.delete_crds_from_group(group) end end # returns an array of CRDs to be postdelete since helm does not # manage the CRDs it creates. - def api_resources - @api_resources ||= YAML.safe_load(File.read(Rails.root.join(API_RESOURCES_PATH))) + def api_groups + @api_groups ||= YAML.safe_load(File.read(Rails.root.join(API_GROUPS_PATH))) end def install_knative_metrics @@ -131,10 +133,6 @@ module Clusters [Gitlab::Kubernetes::KubectlCmd.delete("--ignore-not-found", "-f", METRICS_CONFIG)] end - - def verify_cluster? - cluster&.application_helm_available? && cluster&.platform_kubernetes_rbac? - end end end end diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index 5e7fdd55cb6..4ac33d4e3be 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -13,15 +13,21 @@ module Clusters include ::Clusters::Concerns::ApplicationStatus include ::Clusters::Concerns::ApplicationVersion include ::Clusters::Concerns::ApplicationData + include AfterCommitQueue default_value_for :version, VERSION - after_destroy :disable_prometheus_integration + after_destroy do + run_after_commit do + disable_prometheus_integration + end + end state_machine :status do after_transition any => [:installed] do |application| - application.cluster.projects.each do |project| - project.find_or_initialize_service('prometheus').update!(active: true) + application.run_after_commit do + Clusters::Applications::ActivateServiceWorker + .perform_async(application.cluster_id, ::PrometheusService.to_param) # rubocop:disable CodeReuse/ServiceClass end end end @@ -49,10 +55,10 @@ module Clusters ) end - def upgrade_command(values) - ::Gitlab::Kubernetes::Helm::InstallCommand.new( + def patch_command(values) + ::Gitlab::Kubernetes::Helm::PatchCommand.new( name: name, - version: VERSION, + version: version, rbac: cluster.platform_kubernetes_rbac?, chart: chart, files: files_with_replaced_values(values) @@ -84,19 +90,22 @@ module Clusters # ensures headers containing auth data are appended to original k8s client options options = kube_client.rest_client.options.merge(headers: kube_client.headers) Gitlab::PrometheusClient.new(proxy_url, options) - rescue Kubeclient::HttpError + rescue Kubeclient::HttpError, Errno::ECONNRESET, Errno::ECONNREFUSED # If users have mistakenly set parameters or removed the depended clusters, # `proxy_url` could raise an exception because gitlab can not communicate with the cluster. # Since `PrometheusAdapter#can_query?` is eargely loaded on environement pages in gitlab, # we need to silence the exceptions end + def configured? + kube_client.present? && available? + end + private def disable_prometheus_integration - cluster.projects.each do |project| - project.prometheus_service&.update!(active: false) - end + ::Clusters::Applications::DeactivateServiceWorker + .perform_async(cluster_id, ::PrometheusService.to_param) # rubocop:disable CodeReuse/ServiceClass end def kube_client diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 37ba8a7c97e..fd05fd6bab9 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ApplicationRecord - VERSION = '0.10.1' + VERSION = '0.11.0' self.table_name = 'clusters_applications_runners' diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index f522f3f2fdb..d2eee78f3df 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -23,6 +23,7 @@ module Clusters }.freeze DEFAULT_ENVIRONMENT = '*' KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN' + APPLICATIONS_ASSOCIATIONS = APPLICATIONS.values.map(&:association_name).freeze belongs_to :user belongs_to :management_project, class_name: '::Project', optional: true @@ -33,6 +34,7 @@ module Clusters has_many :cluster_groups, class_name: 'Clusters::Group' has_many :groups, through: :cluster_groups, class_name: '::Group' + has_many :groups_projects, through: :groups, source: :projects, class_name: '::Project' # we force autosave to happen when we save `Cluster` model has_one :provider_gcp, class_name: 'Clusters::Providers::Gcp', autosave: true @@ -117,7 +119,7 @@ module Clusters scope :aws_installed, -> { aws_provided.joins(:provider_aws).merge(Clusters::Providers::Aws.with_status(:created)) } scope :managed, -> { where(managed: true) } - + scope :with_persisted_applications, -> { eager_load(*APPLICATIONS_ASSOCIATIONS) } scope :default_environment, -> { where(environment_scope: DEFAULT_ENVIRONMENT) } scope :for_project_namespace, -> (namespace_id) { joins(:projects).where(projects: { namespace_id: namespace_id }) } @@ -176,6 +178,13 @@ module Clusters end end + def all_projects + return projects if project_type? + return groups_projects if group_type? + + ::Project.all + end + def status_name return cleanup_status_name if cleanup_errored? return :cleanup_ongoing unless cleanup_not_started? @@ -195,9 +204,13 @@ module Clusters { connection_status: retrieve_connection_status } end + def persisted_applications + APPLICATIONS_ASSOCIATIONS.map(&method(:public_send)).compact + end + def applications - APPLICATIONS.values.map do |application_class| - public_send(application_class.association_name) || public_send("build_#{application_class.association_name}") # rubocop:disable GitlabSecurity/PublicSend + APPLICATIONS_ASSOCIATIONS.map do |association_name| + public_send(association_name) || public_send("build_#{association_name}") # rubocop:disable GitlabSecurity/PublicSend end end @@ -236,14 +249,9 @@ module Clusters end def kubernetes_namespace_for(environment) - project = environment.project - persisted_namespace = Clusters::KubernetesNamespaceFinder.new( - self, - project: project, - environment_name: environment.name - ).execute - - persisted_namespace&.namespace || Gitlab::Kubernetes::DefaultNamespace.new(self, project: project).from_environment_slug(environment.slug) + managed_namespace(environment) || + ci_configured_namespace(environment) || + default_namespace(environment) end def allow_user_defined_namespace? @@ -262,6 +270,25 @@ module Clusters end end + def delete_cached_resources! + kubernetes_namespaces.delete_all(:delete_all) + end + + def clusterable + return unless cluster_type + + case cluster_type + when 'project_type' + project + when 'group_type' + group + when 'instance_type' + instance + else + raise NotImplementedError + end + end + private def unique_management_project_environment_scope @@ -276,6 +303,25 @@ module Clusters end end + def managed_namespace(environment) + Clusters::KubernetesNamespaceFinder.new( + self, + project: environment.project, + environment_name: environment.name + ).execute&.namespace + end + + def ci_configured_namespace(environment) + environment.last_deployable&.expanded_kubernetes_namespace + end + + def default_namespace(environment) + Gitlab::Kubernetes::DefaultNamespace.new( + self, + project: environment.project + ).from_environment_slug(environment.slug) + end + def instance_domain @instance_domain ||= Gitlab::CurrentSettings.auto_devops_domain end @@ -289,7 +335,7 @@ module Clusters rescue Kubeclient::HttpError => e kubeclient_error_status(e.message) rescue => e - Gitlab::Sentry.track_acceptable_exception(e, extra: { cluster_id: id }) + Gitlab::ErrorTracking.track_exception(e, cluster_id: id) :unknown_failure else diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb index 21b98534808..f6431f5bac3 100644 --- a/app/models/clusters/concerns/application_core.rb +++ b/app/models/clusters/concerns/application_core.rb @@ -76,7 +76,7 @@ module Clusters message: error.message }) - Gitlab::Sentry.track_acceptable_exception(error, extra: { cluster_id: cluster&.id, application_id: id }) + Gitlab::ErrorTracking.track_exception(error, cluster_id: cluster&.id, application_id: id) end end end diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index 314ef78757d..ae720065387 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -63,7 +63,7 @@ module Clusters default_value_for :authorization_type, :rbac - def predefined_variables(project:, environment_name:) + def predefined_variables(project:, environment_name:, kubernetes_namespace: nil) Gitlab::Ci::Variables::Collection.new.tap do |variables| variables.append(key: 'KUBE_URL', value: api_url) @@ -74,15 +74,15 @@ module Clusters end if !cluster.managed? || cluster.management_project == project - namespace = Gitlab::Kubernetes::DefaultNamespace.new(cluster, project: project).from_environment_name(environment_name) + namespace = kubernetes_namespace || default_namespace(project, environment_name: environment_name) variables .append(key: 'KUBE_TOKEN', value: token, public: false, masked: true) .append(key: 'KUBE_NAMESPACE', value: namespace) .append(key: 'KUBECONFIG', value: kubeconfig(namespace), public: false, file: true) - elsif kubernetes_namespace = find_persisted_namespace(project, environment_name: environment_name) - variables.concat(kubernetes_namespace.predefined_variables) + elsif persisted_namespace = find_persisted_namespace(project, environment_name: environment_name) + variables.concat(persisted_namespace.predefined_variables) end variables.concat(cluster.predefined_variables) @@ -107,6 +107,13 @@ module Clusters private + def default_namespace(project, environment_name:) + Gitlab::Kubernetes::DefaultNamespace.new( + cluster, + project: project + ).from_environment_name(environment_name) + end + def find_persisted_namespace(project, environment_name:) Clusters::KubernetesNamespaceFinder.new( cluster, diff --git a/app/models/clusters/providers/aws.rb b/app/models/clusters/providers/aws.rb index 78eb75ddcc0..faf587fb83d 100644 --- a/app/models/clusters/providers/aws.rb +++ b/app/models/clusters/providers/aws.rb @@ -8,9 +8,11 @@ module Clusters self.table_name = 'cluster_providers_aws' + DEFAULT_REGION = 'us-east-1' + belongs_to :cluster, inverse_of: :provider_aws, class_name: 'Clusters::Cluster' - default_value_for :region, 'us-east-1' + default_value_for :region, DEFAULT_REGION default_value_for :num_nodes, 3 default_value_for :instance_type, 'm5.large' diff --git a/app/models/commit.rb b/app/models/commit.rb index aae49c36899..460725b2016 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -12,6 +12,7 @@ class Commit include StaticModel include Presentable include ::Gitlab::Utils::StrongMemoize + include ActsAsPaginatedDiff include CacheMarkdownField attr_mentionable :safe_message, pipeline: :single_line @@ -246,7 +247,7 @@ class Commit def lazy_author BatchLoader.for(author_email.downcase).batch do |emails, loader| - users = User.by_any_email(emails).includes(:emails) + users = User.by_any_email(emails, confirmed: true).includes(:emails) emails.each do |email| user = users.find { |u| u.any_email?(email) } @@ -263,8 +264,8 @@ class Commit end request_cache(:author) { author_email.downcase } - def committer - @committer ||= User.find_by_any_email(committer_email) + def committer(confirmed: true) + @committer ||= User.find_by_any_email(committer_email, confirmed: confirmed) end def parents @@ -281,6 +282,10 @@ class Commit project.notes.for_commit_id(self.id) end + def user_mentions + CommitUserMention.where(commit_id: self.id) + end + def discussion_notes notes.non_diff_notes end @@ -464,8 +469,20 @@ class Commit "commit:#{sha}" end + def expire_note_etag_cache + super + + expire_note_etag_cache_for_related_mrs + end + private + def expire_note_etag_cache_for_related_mrs + MergeRequest.includes(target_project: :namespace).by_commit_sha(id).find_each do |mr| + mr.expire_note_etag_cache + end + end + def commit_reference(from, referable_commit_id, full: false) reference = project.to_reference(from, full: full) diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 39a6247b3b2..8d38835fb3b 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -17,7 +17,7 @@ class CommitStatus < ApplicationRecord belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline' delegate :commit, to: :pipeline - delegate :sha, :short_sha, to: :pipeline + delegate :sha, :short_sha, :before_sha, to: :pipeline validates :pipeline, presence: true, unless: :importing? validates :name, presence: true, unless: :importing? @@ -47,6 +47,12 @@ class CommitStatus < ApplicationRecord scope :after_stage, -> (index) { where('stage_idx > ?', index) } scope :processables, -> { where(type: %w[Ci::Build Ci::Bridge]) } scope :for_ids, -> (ids) { where(id: ids) } + scope :for_ref, -> (ref) { where(ref: ref) } + scope :by_name, -> (name) { where(name: name) } + + scope :for_project_paths, -> (paths) do + where(project: Project.where_full_path_in(Array(paths))) + end scope :with_preloads, -> do preload(:project, :user) @@ -176,10 +182,6 @@ class CommitStatus < ApplicationRecord will_save_change_to_status? end - def before_sha - pipeline.before_sha || Gitlab::Git::BLANK_SHA - end - def group_name name.to_s.gsub(%r{\d+[\s:/\\]+\d+\s*}, '').strip end diff --git a/app/models/commit_user_mention.rb b/app/models/commit_user_mention.rb new file mode 100644 index 00000000000..680d20b61cf --- /dev/null +++ b/app/models/commit_user_mention.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class CommitUserMention < UserMention + belongs_to :note +end diff --git a/app/models/compare.rb b/app/models/compare.rb index f1ed84ab5a5..9b214171f07 100644 --- a/app/models/compare.rb +++ b/app/models/compare.rb @@ -4,6 +4,7 @@ require 'set' class Compare include Gitlab::Utils::StrongMemoize + include ActsAsPaginatedDiff delegate :same, :head, :base, to: :@compare diff --git a/app/models/concerns/acts_as_paginated_diff.rb b/app/models/concerns/acts_as_paginated_diff.rb new file mode 100644 index 00000000000..4ce2f99e63f --- /dev/null +++ b/app/models/concerns/acts_as_paginated_diff.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module ActsAsPaginatedDiff + # Comparisons going back to the repository will need proper batch + # loading (https://gitlab.com/gitlab-org/gitlab/issues/32859). + # For now, we're returning all the diffs available with + # no pagination data. + def diffs_in_batch(_batch_page, _batch_size, diff_options:) + diffs(diff_options) + end +end diff --git a/app/models/concerns/analytics/cycle_analytics/stage.rb b/app/models/concerns/analytics/cycle_analytics/stage.rb index 0e07806dd6f..dde73b567db 100644 --- a/app/models/concerns/analytics/cycle_analytics/stage.rb +++ b/app/models/concerns/analytics/cycle_analytics/stage.rb @@ -26,6 +26,7 @@ module Analytics alias_attribute :custom_stage?, :custom scope :default_stages, -> { where(custom: false) } scope :ordered, -> { order(:relative_position, :id) } + scope :for_list, -> { includes(:start_event_label, :end_event_label).ordered } end def parent=(_) diff --git a/app/models/concerns/blob_active_model.rb b/app/models/concerns/blob_active_model.rb new file mode 100644 index 00000000000..89157e90e34 --- /dev/null +++ b/app/models/concerns/blob_active_model.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# To be included in blob classes which are to be +# treated as ActiveModel. +# +# The blob class must respond_to `project` +module BlobActiveModel + extend ActiveSupport::Concern + + class_methods do + def declarative_policy_class + 'BlobPolicy' + end + end + + def to_ability_name + 'blob' + end +end diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb index 49d6f3d399c..5ff537a7837 100644 --- a/app/models/concerns/ci/contextable.rb +++ b/app/models/concerns/ci/contextable.rb @@ -15,7 +15,7 @@ module Ci variables.concat(project.predefined_variables) variables.concat(pipeline.predefined_variables) variables.concat(runner.predefined_variables) if runnable? && runner - variables.concat(project.deployment_variables(environment: environment)) if environment + variables.concat(deployment_variables(environment: environment)) variables.concat(yaml_variables) variables.concat(user_variables) variables.concat(secret_group_variables) @@ -54,49 +54,33 @@ module Ci end end - def predefined_variables # rubocop:disable Metrics/AbcSize + def predefined_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| - variables.append(key: 'CI', value: 'true') - variables.append(key: 'GITLAB_CI', value: 'true') - variables.append(key: 'GITLAB_FEATURES', value: project.licensed_features.join(',')) - variables.append(key: 'CI_SERVER_HOST', value: Gitlab.config.gitlab.host) - variables.append(key: 'CI_SERVER_NAME', value: 'GitLab') - variables.append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION) - variables.append(key: 'CI_SERVER_VERSION_MAJOR', value: Gitlab.version_info.major.to_s) - variables.append(key: 'CI_SERVER_VERSION_MINOR', value: Gitlab.version_info.minor.to_s) - variables.append(key: 'CI_SERVER_VERSION_PATCH', value: Gitlab.version_info.patch.to_s) - variables.append(key: 'CI_SERVER_REVISION', value: Gitlab.revision) variables.append(key: 'CI_JOB_NAME', value: name) variables.append(key: 'CI_JOB_STAGE', value: stage) - variables.append(key: 'CI_COMMIT_SHA', value: sha) - variables.append(key: 'CI_COMMIT_SHORT_SHA', value: short_sha) - variables.append(key: 'CI_COMMIT_BEFORE_SHA', value: before_sha) - variables.append(key: 'CI_COMMIT_REF_NAME', value: source_ref) - variables.append(key: 'CI_COMMIT_REF_SLUG', value: source_ref_slug) - variables.append(key: "CI_COMMIT_TAG", value: ref) if tag? - variables.append(key: "CI_PIPELINE_TRIGGERED", value: 'true') if trigger_request - variables.append(key: "CI_JOB_MANUAL", value: 'true') if action? - variables.append(key: "CI_NODE_INDEX", value: self.options[:instance].to_s) if self.options&.include?(:instance) - variables.append(key: "CI_NODE_TOTAL", value: (self.options&.dig(:parallel) || 1).to_s) - variables.append(key: "CI_DEFAULT_BRANCH", value: project.default_branch) - variables.concat(legacy_variables) - end - end + variables.append(key: 'CI_JOB_MANUAL', value: 'true') if action? + variables.append(key: 'CI_PIPELINE_TRIGGERED', value: 'true') if trigger_request - def legacy_variables - Gitlab::Ci::Variables::Collection.new.tap do |variables| - variables.append(key: 'CI_BUILD_REF', value: sha) - variables.append(key: 'CI_BUILD_BEFORE_SHA', value: before_sha) - variables.append(key: 'CI_BUILD_REF_NAME', value: source_ref) - variables.append(key: 'CI_BUILD_REF_SLUG', value: source_ref_slug) + variables.append(key: 'CI_NODE_INDEX', value: self.options[:instance].to_s) if self.options&.include?(:instance) + variables.append(key: 'CI_NODE_TOTAL', value: (self.options&.dig(:parallel) || 1).to_s) + + # legacy variables variables.append(key: 'CI_BUILD_NAME', value: name) variables.append(key: 'CI_BUILD_STAGE', value: stage) - variables.append(key: "CI_BUILD_TAG", value: ref) if tag? - variables.append(key: "CI_BUILD_TRIGGERED", value: 'true') if trigger_request - variables.append(key: "CI_BUILD_MANUAL", value: 'true') if action? + variables.append(key: 'CI_BUILD_TRIGGERED', value: 'true') if trigger_request + variables.append(key: 'CI_BUILD_MANUAL', value: 'true') if action? end end + def deployment_variables(environment:) + return [] unless environment + + project.deployment_variables( + environment: environment, + kubernetes_namespace: expanded_kubernetes_namespace + ) + end + def secret_group_variables return [] unless project.group diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb index 17d431bacf2..9bfe76728e4 100644 --- a/app/models/concerns/ci/metadatable.rb +++ b/app/models/concerns/ci/metadatable.rb @@ -17,6 +17,7 @@ module Ci delegate :timeout, to: :metadata, prefix: true, allow_nil: true delegate :interruptible, to: :metadata, prefix: false, allow_nil: true delegate :has_exposed_artifacts?, to: :metadata, prefix: false, allow_nil: true + delegate :environment_auto_stop_in, to: :metadata, prefix: false, allow_nil: true before_create :ensure_metadata end @@ -47,8 +48,11 @@ module Ci def options=(value) write_metadata_attribute(:options, :config_options, value) - # Store presence of exposed artifacts in build metadata to make it easier to query - ensure_metadata.has_exposed_artifacts = value&.dig(:artifacts, :expose_as).present? + ensure_metadata.tap do |metadata| + # Store presence of exposed artifacts in build metadata to make it easier to query + metadata.has_exposed_artifacts = value&.dig(:artifacts, :expose_as).present? + metadata.environment_auto_stop_in = value&.dig(:environment, :auto_stop_in) + end end def yaml_variables=(value) diff --git a/app/models/concerns/ci/pipeline_delegator.rb b/app/models/concerns/ci/pipeline_delegator.rb index 76e0cbc7dff..9f95dc38422 100644 --- a/app/models/concerns/ci/pipeline_delegator.rb +++ b/app/models/concerns/ci/pipeline_delegator.rb @@ -13,8 +13,6 @@ module Ci included do delegate :merge_request_event?, :merge_request_ref?, - :source_ref, - :source_ref_slug, :legacy_detached_merge_request_pipeline?, :merge_train_pipeline?, to: :pipeline end diff --git a/app/models/concerns/ci/processable.rb b/app/models/concerns/ci/processable.rb index ed0087f34d4..c229358ad17 100644 --- a/app/models/concerns/ci/processable.rb +++ b/app/models/concerns/ci/processable.rb @@ -14,6 +14,8 @@ module Ci has_many :needs, class_name: 'Ci::BuildNeed', foreign_key: :build_id, inverse_of: :build accepts_nested_attributes_for :needs + + scope :preload_needs, -> { preload(:needs) } end def schedulable? diff --git a/app/models/concerns/diff_positionable_note.rb b/app/models/concerns/diff_positionable_note.rb index 195d9e107c5..6484a3157b1 100644 --- a/app/models/concerns/diff_positionable_note.rb +++ b/app/models/concerns/diff_positionable_note.rb @@ -5,7 +5,7 @@ module DiffPositionableNote included do delegate :on_text?, :on_image?, to: :position, allow_nil: true before_validation :set_original_position, on: :create - before_validation :update_position, on: :create, if: :on_text? + before_validation :update_position, on: :create, if: :on_text?, unless: :importing? serialize :original_position, Gitlab::Diff::Position # rubocop:disable Cop/ActiveRecordSerialize serialize :position, Gitlab::Diff::Position # rubocop:disable Cop/ActiveRecordSerialize diff --git a/app/models/concerns/expirable.rb b/app/models/concerns/expirable.rb index 1f274487935..512822089ba 100644 --- a/app/models/concerns/expirable.rb +++ b/app/models/concerns/expirable.rb @@ -3,6 +3,8 @@ module Expirable extend ActiveSupport::Concern + DAYS_TO_EXPIRE = 7 + included do scope :expired, -> { where('expires_at <= ?', Time.current) } end @@ -16,6 +18,6 @@ module Expirable end def expires_soon? - expires? && expires_at < 7.days.from_now + expires? && expires_at < DAYS_TO_EXPIRE.days.from_now end end diff --git a/app/models/concerns/group_descendant.rb b/app/models/concerns/group_descendant.rb index 7e6a20c27e8..67953105bed 100644 --- a/app/models/concerns/group_descendant.rb +++ b/app/models/concerns/group_descendant.rb @@ -48,11 +48,11 @@ module GroupDescendant extras = { parent: parent.inspect, child: child.inspect, - preloaded: preloaded.map(&:full_path) + preloaded: preloaded.map(&:full_path), + issue_url: 'https://gitlab.com/gitlab-org/gitlab-foss/issues/49404' } - issue_url = 'https://gitlab.com/gitlab-org/gitlab-foss/issues/49404' - Gitlab::Sentry.track_exception(exception, issue_url: issue_url, extra: extras) + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(exception, extras) end if parent.nil? && hierarchy_top.present? diff --git a/app/models/concerns/ignorable_columns.rb b/app/models/concerns/ignorable_columns.rb new file mode 100644 index 00000000000..744a1f0b5f3 --- /dev/null +++ b/app/models/concerns/ignorable_columns.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module IgnorableColumns + extend ActiveSupport::Concern + + ColumnIgnore = Struct.new(:remove_after, :remove_with) do + def safe_to_remove? + Date.today > remove_after + end + + def to_s + "(#{remove_after}, #{remove_with})" + end + end + + class_methods do + # Ignore database columns in a model + # + # Indicate the earliest date and release we can stop ignoring the column with +remove_after+ (a date string) and +remove_with+ (a release) + def ignore_columns(*columns, remove_after:, remove_with:) + raise ArgumentError, 'Please indicate when we can stop ignoring columns with remove_after (date string YYYY-MM-DD), example: ignore_columns(:name, remove_after: \'2019-12-01\', remove_with: \'12.6\')' unless remove_after =~ Gitlab::Regex.utc_date_regex + raise ArgumentError, 'Please indicate in which release we can stop ignoring columns with remove_with, example: ignore_columns(:name, remove_after: \'2019-12-01\', remove_with: \'12.6\')' unless remove_with + + self.ignored_columns += columns.flatten # rubocop:disable Cop/IgnoredColumns + + columns.flatten.each do |column| + self.ignored_columns_details[column.to_sym] = ColumnIgnore.new(Date.parse(remove_after), remove_with) + end + end + + alias_method :ignore_column, :ignore_columns + + def ignored_columns_details + unless defined?(@ignored_columns_details) + IGNORE_COLUMN_MUTEX.synchronize do + @ignored_columns_details ||= superclass.try(:ignored_columns_details)&.dup || {} + end + end + + @ignored_columns_details + end + + IGNORE_COLUMN_MUTEX = Mutex.new + end +end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 01cd1e0224b..9e3fba139e3 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -23,7 +23,6 @@ module Issuable include Sortable include CreatedAtFilterable include UpdatedAtFilterable - include IssuableStates include ClosedAtFilterable include VersionedDescription @@ -100,6 +99,8 @@ module Issuable scope :of_milestones, ->(ids) { where(milestone_id: ids) } scope :any_milestone, -> { where('milestone_id IS NOT NULL') } scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) } + scope :any_release, -> { joins_milestone_releases } + scope :with_release, -> (tag, project_id) { joins_milestone_releases.where( milestones: { releases: { tag: tag, project_id: project_id } } ) } scope :opened, -> { with_state(:opened) } scope :only_opened, -> { with_state(:opened) } scope :closed, -> { with_state(:closed) } @@ -121,6 +122,16 @@ module Issuable scope :order_milestone_due_desc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC')) } scope :order_milestone_due_asc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC')) } + scope :without_release, -> do + joins("LEFT OUTER JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id") + .where('milestone_releases.release_id IS NULL') + end + + scope :joins_milestone_releases, -> do + joins("JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id + JOIN releases ON milestone_releases.release_id = releases.id").distinct + end + scope :without_label, -> { joins("LEFT OUTER JOIN label_links ON label_links.target_type = '#{name}' AND label_links.target_id = #{table_name}.id").where(label_links: { id: nil }) } scope :any_label, -> { joins(:label_links).group(:id) } scope :join_project, -> { joins(:project) } @@ -137,26 +148,6 @@ module Issuable strip_attributes :title - # The state_machine gem will reset the value of state_id unless it - # is a raw attribute passed in here: - # https://gitlab.com/gitlab-org/gitlab/issues/35746#note_241148787 - # - # This assumes another initialize isn't defined. Otherwise this - # method may need to be prepended. - def initialize(attributes = nil) - if attributes.is_a?(Hash) - attr = attributes.symbolize_keys - - if attr.key?(:state) && !attr.key?(:state_id) - value = attr.delete(:state) - state_id = self.class.available_states[value] - attributes[:state_id] = state_id if state_id - end - end - - super(attributes) - end - # We want to use optimistic lock for cases when only title or description are involved # http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html def locking_enabled? @@ -174,7 +165,7 @@ module Issuable private def milestone_is_valid - errors.add(:milestone_id, message: "is invalid") if milestone_id.present? && !milestone_available? + errors.add(:milestone_id, message: "is invalid") if respond_to?(:milestone_id) && milestone_id.present? && !milestone_available? end def description_max_length_for_new_records_is_valid diff --git a/app/models/concerns/issuable_states.rb b/app/models/concerns/issuable_states.rb deleted file mode 100644 index f0b9f0d1f3a..00000000000 --- a/app/models/concerns/issuable_states.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module IssuableStates - extend ActiveSupport::Concern - - # The state:string column is being migrated to state_id:integer column - # This is a temporary hook to keep state column in sync until it is removed. - # Check https: https://gitlab.com/gitlab-org/gitlab/issues/33814 for more information - # The state column can be safely removed after 2019-10-27 - included do - before_save :sync_issuable_deprecated_state - end - - def sync_issuable_deprecated_state - return if self.is_a?(Epic) - return unless respond_to?(:state) - return if state_id.nil? - - deprecated_state = self.class.available_states.key(state_id) - - self.write_attribute(:state, deprecated_state) - end -end diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index 9b6c57261d8..b43b91699ab 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -80,6 +80,66 @@ module Mentionable all_references(current_user).users end + def store_mentions! + # if store_mentioned_users_to_db feature flag is not enabled then consider storing operation as succeeded + # because we wrap this method in transaction with with_transaction_returning_status, and we need the status to be + # successful if mentionable.save is successful. + # + # This line will get removed when we remove the feature flag. + return true unless store_mentioned_users_to_db_enabled? + + refs = all_references(self.author) + + references = {} + references[:mentioned_users_ids] = refs.mentioned_users&.pluck(:id).presence + references[:mentioned_groups_ids] = refs.mentioned_groups&.pluck(:id).presence + references[:mentioned_projects_ids] = refs.mentioned_projects&.pluck(:id).presence + + # One retry should be enough as next time `model_user_mention` should return the existing mention record, that + # threw the `ActiveRecord::RecordNotUnique` exception in first place. + self.class.safe_ensure_unique(retries: 1) do + user_mention = model_user_mention + user_mention.mentioned_users_ids = references[:mentioned_users_ids] + user_mention.mentioned_groups_ids = references[:mentioned_groups_ids] + user_mention.mentioned_projects_ids = references[:mentioned_projects_ids] + + if user_mention.has_mentions? + user_mention.save! + elsif user_mention.persisted? + user_mention.destroy! + end + + true + end + end + + def referenced_users + User.where(id: user_mentions.select("unnest(mentioned_users_ids)")) + end + + def referenced_projects(current_user = nil) + Project.where(id: user_mentions.select("unnest(mentioned_projects_ids)")).public_or_visible_to_user(current_user) + end + + def referenced_project_users(current_user = nil) + User.joins(:project_members).where(members: { source_id: referenced_projects(current_user) }).distinct + end + + def referenced_groups(current_user = nil) + # TODO: IMPORTANT: Revisit before using it. + # Check DB data for max mentioned groups per mentionable: + # + # select issue_id, count(mentions_count.men_gr_id) gr_count from + # (select DISTINCT unnest(mentioned_groups_ids) as men_gr_id, issue_id + # from issue_user_mentions group by issue_id, mentioned_groups_ids) as mentions_count + # group by mentions_count.issue_id order by gr_count desc limit 10 + Group.where(id: user_mentions.select("unnest(mentioned_groups_ids)")).public_or_visible_to_user(current_user) + end + + def referenced_group_users(current_user = nil) + User.joins(:group_members).where(members: { source_id: referenced_groups }).distinct + end + def directly_addressed_users(current_user = nil) all_references(current_user).directly_addressed_users end @@ -171,6 +231,26 @@ module Mentionable def mentionable_params {} end + + # User mention that is parsed from model description rather then its related notes. + # Models that have a descriprion attribute like Issue, MergeRequest, Epic, Snippet may have such a user mention. + # Other mentionable models like Commit, DesignManagement::Design, will never have such record as those do not have + # a description attribute. + # + # Using this method followed by a call to *save* may result in *ActiveRecord::RecordNotUnique* exception + # in a multithreaded environment. Make sure to use it within a *safe_ensure_unique* block. + def model_user_mention + user_mentions.where(note_id: nil).first_or_initialize + end + + # We need this method to be checking that store_mentioned_users_to_db feature flag is enabled at the group level + # and not the project level as epics are defined at group level and we want to have epics store user mentions as well + # for the test period. + # During the test period the flag should be enabled at the group level. + def store_mentioned_users_to_db_enabled? + return Feature.enabled?(:store_mentioned_users_to_db, self.project&.group) if self.respond_to?(:project) + return Feature.enabled?(:store_mentioned_users_to_db, self.group) if self.respond_to?(:group) + end end Mentionable.prepend_if_ee('EE::Mentionable') diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index b1a7d7ec819..88e752e51e7 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -53,7 +53,7 @@ module Milestoneish end def sorted_issues(user) - issues_visible_to_user(user).preload_associations.sort_by_attribute('label_priority') + issues_visible_to_user(user).preload_associated_models.sort_by_attribute('label_priority') end def sorted_merge_requests(user) diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb index 9df77b565da..99da8b81398 100644 --- a/app/models/concerns/prometheus_adapter.rb +++ b/app/models/concerns/prometheus_adapter.rb @@ -16,6 +16,14 @@ module PrometheusAdapter raise NotImplementedError end + # This is a light-weight check if a prometheus client is properly configured. + def configured? + raise NotImplemented + end + + # This is a heavy-weight check if a prometheus is properly configured and accesible from GitLab. + # This actually sends a request to an external service and often it could take a long time, + # Please consider using `configured?` instead if the process is running on unicorn/puma threads. def can_query? prometheus_client.present? end diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb index f9a52cd54bd..693f9ab8dc5 100644 --- a/app/models/concerns/reactive_caching.rb +++ b/app/models/concerns/reactive_caching.rb @@ -129,14 +129,17 @@ module ReactiveCaching def exclusively_update_reactive_cache!(*args) locking_reactive_cache(*args) do + key = full_reactive_cache_key(*args) + if within_reactive_cache_lifetime?(*args) enqueuing_update(*args) do - key = full_reactive_cache_key(*args) new_value = calculate_reactive_cache(*args) old_value = Rails.cache.read(key) Rails.cache.write(key, new_value) reactive_cache_updated(*args) if new_value != old_value end + else + Rails.cache.delete(key) end end end diff --git a/app/models/concerns/safe_url.rb b/app/models/concerns/safe_url.rb new file mode 100644 index 00000000000..febca7d241f --- /dev/null +++ b/app/models/concerns/safe_url.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module SafeUrl + extend ActiveSupport::Concern + + def safe_url(usernames_whitelist: []) + return if url.nil? + + uri = URI.parse(url) + uri.password = '*****' if uri.password + uri.user = '*****' if uri.user && !usernames_whitelist.include?(uri.user) + uri.to_s + rescue URI::Error + end +end diff --git a/app/models/concerns/sha256_attribute.rb b/app/models/concerns/sha256_attribute.rb new file mode 100644 index 00000000000..1bd1ad177a2 --- /dev/null +++ b/app/models/concerns/sha256_attribute.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Sha256Attribute + extend ActiveSupport::Concern + + class_methods do + def sha256_attribute(name) + return if ENV['STATIC_VERIFICATION'] + + validate_binary_column_exists!(name) unless Rails.env.production? + + attribute(name, Gitlab::Database::Sha256Attribute.new) + end + + # This only gets executed in non-production environments as an additional check to ensure + # the column is the correct type. In production it should behave like any other attribute. + # See https://gitlab.com/gitlab-org/gitlab/merge_requests/5502 for more discussion + def validate_binary_column_exists!(name) + return unless database_exists? + + unless table_exists? + warn "WARNING: sha256_attribute #{name.inspect} is invalid since the table doesn't exist - you may need to run database migrations" + return + end + + column = columns.find { |c| c.name == name.to_s } + + unless column + warn "WARNING: sha256_attribute #{name.inspect} is invalid since the column doesn't exist - you may need to run database migrations" + return + end + + unless column.type == :binary + raise ArgumentError.new("sha256_attribute #{name.inspect} is invalid since the column type is not :binary") + end + rescue => error + Gitlab::AppLogger.error "Sha256Attribute initialization: #{error.message}" + raise + end + + def database_exists? + ApplicationRecord.connection + + true + rescue + false + end + end +end diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb index 9c2b0372d54..da4f2a79895 100644 --- a/app/models/concerns/storage/legacy_namespace.rb +++ b/app/models/concerns/storage/legacy_namespace.rb @@ -37,8 +37,10 @@ module Storage send_update_instructions write_projects_repository_config rescue => e - # Raise if development/test environment, else just notify Sentry - Gitlab::Sentry.track_exception(e, extra: { full_path_before_last_save: full_path_before_last_save, full_path: full_path, action: 'move_dir' }) + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e, + full_path_before_last_save: full_path_before_last_save, + full_path: full_path, + action: 'move_dir') end true # false would cancel later callbacks but not rollback diff --git a/app/models/concerns/update_project_statistics.rb b/app/models/concerns/update_project_statistics.rb index 869b3490f3f..a84fb1cf56d 100644 --- a/app/models/concerns/update_project_statistics.rb +++ b/app/models/concerns/update_project_statistics.rb @@ -49,8 +49,7 @@ module UpdateProjectStatistics attr = self.class.statistic_attribute delta = read_attribute(attr).to_i - attribute_before_last_save(attr).to_i - update_project_statistics(delta) - schedule_namespace_aggregation_worker + schedule_update_project_statistic(delta) end def update_project_statistics_attribute_changed? @@ -58,24 +57,35 @@ module UpdateProjectStatistics end def update_project_statistics_after_destroy - update_project_statistics(-read_attribute(self.class.statistic_attribute).to_i) + delta = -read_attribute(self.class.statistic_attribute).to_i - schedule_namespace_aggregation_worker + schedule_update_project_statistic(delta) end def project_destroyed? project.pending_delete? end - def update_project_statistics(delta) - ProjectStatistics.increment_statistic(project_id, self.class.project_statistics_name, delta) - end + def schedule_update_project_statistic(delta) + return if delta.zero? + + if Feature.enabled?(:update_project_statistics_after_commit, default_enabled: true) + # Update ProjectStatistics after the transaction + run_after_commit do + ProjectStatistics.increment_statistic( + project_id, self.class.project_statistics_name, delta) + end + else + # Use legacy-way to update within transaction + ProjectStatistics.increment_statistic( + project_id, self.class.project_statistics_name, delta) + end - def schedule_namespace_aggregation_worker run_after_commit do next if project.nil? - Namespaces::ScheduleAggregationWorker.perform_async(project.namespace_id) + Namespaces::ScheduleAggregationWorker.perform_async( + project.namespace_id) end end end diff --git a/app/models/container_expiration_policy.rb b/app/models/container_expiration_policy.rb new file mode 100644 index 00000000000..f60a0179c83 --- /dev/null +++ b/app/models/container_expiration_policy.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class ContainerExpirationPolicy < ApplicationRecord + belongs_to :project, inverse_of: :container_expiration_policy + + validates :project, presence: true + validates :enabled, inclusion: { in: [true, false] } + validates :cadence, presence: true, inclusion: { in: ->(_) { self.cadence_options.stringify_keys } } + validates :older_than, inclusion: { in: ->(_) { self.older_than_options.stringify_keys } }, allow_nil: true + validates :keep_n, inclusion: { in: ->(_) { self.keep_n_options.keys } }, allow_nil: true + + def self.keep_n_options + { + 1 => _('%{tags} tag per image name') % { tags: 1 }, + 5 => _('%{tags} tags per image name') % { tags: 5 }, + 10 => _('%{tags} tags per image name') % { tags: 10 }, + 25 => _('%{tags} tags per image name') % { tags: 25 }, + 50 => _('%{tags} tags per image name') % { tags: 50 }, + 100 => _('%{tags} tags per image name') % { tags: 100 } + } + end + + def self.cadence_options + { + '1d': _('Every day'), + '7d': _('Every week'), + '14d': _('Every two weeks'), + '1month': _('Every month'), + '3month': _('Every three months') + } + end + + def self.older_than_options + { + '7d': _('%{days} days until tags are automatically removed') % { days: 7 }, + '14d': _('%{days} days until tags are automatically removed') % { days: 14 }, + '30d': _('%{days} days until tags are automatically removed') % { days: 30 }, + '90d': _('%{days} days until tags are automatically removed') % { days: 90 } + } + end +end diff --git a/app/models/dashboard_group_milestone.rb b/app/models/dashboard_group_milestone.rb index cf6094682f3..48c09f4cd6b 100644 --- a/app/models/dashboard_group_milestone.rb +++ b/app/models/dashboard_group_milestone.rb @@ -22,4 +22,8 @@ class DashboardGroupMilestone < GlobalMilestone def dashboard_milestone? true end + + def merge_requests_enabled? + true + end end diff --git a/app/models/dashboard_milestone.rb b/app/models/dashboard_milestone.rb index 9b377b70e5b..fd59b94b737 100644 --- a/app/models/dashboard_milestone.rb +++ b/app/models/dashboard_milestone.rb @@ -12,4 +12,8 @@ class DashboardMilestone < GlobalMilestone def project_milestone? true end + + def merge_requests_enabled? + project.merge_requests_enabled? + end end diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb index 22ab326a0ab..793ea3c29c3 100644 --- a/app/models/deploy_key.rb +++ b/app/models/deploy_key.rb @@ -2,15 +2,16 @@ class DeployKey < Key include FromUnion + include IgnorableColumns has_many :deploy_keys_projects, inverse_of: :deploy_key, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :projects, through: :deploy_keys_projects scope :in_projects, ->(projects) { joins(:deploy_keys_projects).where('deploy_keys_projects.project_id in (?)', projects) } scope :are_public, -> { where(public: true) } - scope :with_projects, -> { includes(deploy_keys_projects: { project: [:route, :namespace] }) } + scope :with_projects, -> { includes(deploy_keys_projects: { project: [:route, namespace: :route] }) } - self.ignored_columns += %i[can_push] + ignore_column :can_push, remove_after: '2019-12-15', remove_with: '12.6' accepts_nested_attributes_for :deploy_keys_projects @@ -23,7 +24,7 @@ class DeployKey < Key end def almost_orphaned? - self.deploy_keys_projects.count == 1 + self.deploy_keys_projects.size == 1 end def destroyed_when_orphaned? @@ -43,7 +44,11 @@ class DeployKey < Key end def deploy_keys_project_for(project) - deploy_keys_projects.find_by(project: project) + if association(:deploy_keys_projects).loaded? + deploy_keys_projects.find { |dkp| dkp.project_id.eql?(project&.id) } + else + deploy_keys_projects.find_by(project: project) + end end def projects_with_write_access diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 4a38912db9b..994e69912b6 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -4,6 +4,8 @@ class Deployment < ApplicationRecord include AtomicInternalId include IidRoutes include AfterCommitQueue + include UpdatedAtFilterable + include Gitlab::Utils::StrongMemoize belongs_to :project, required: true belongs_to :environment, required: true @@ -125,6 +127,12 @@ class Deployment < ApplicationRecord @scheduled_actions ||= deployable.try(:other_scheduled_actions) end + def playable_build + strong_memoize(:playable_build) do + deployable.try(:playable?) ? deployable : nil + end + end + def includes_commit?(commit) return false unless commit @@ -209,6 +217,23 @@ class Deployment < ApplicationRecord SQL end + # Changes the status of a deployment and triggers the correspinding state + # machine events. + def update_status(status) + case status + when 'running' + run + when 'success' + succeed + when 'failed' + drop + when 'canceled' + cancel + else + raise ArgumentError, "The status #{status.inspect} is invalid" + end + end + private def ref_path diff --git a/app/models/conversational_development_index/card.rb b/app/models/dev_ops_score/card.rb index f9180bdd97b..b1894cf4138 100644 --- a/app/models/conversational_development_index/card.rb +++ b/app/models/dev_ops_score/card.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ConversationalDevelopmentIndex +module DevOpsScore class Card attr_accessor :metric, :title, :description, :feature, :blog, :docs diff --git a/app/models/conversational_development_index/idea_to_production_step.rb b/app/models/dev_ops_score/idea_to_production_step.rb index e78a734693c..d892793cf97 100644 --- a/app/models/conversational_development_index/idea_to_production_step.rb +++ b/app/models/dev_ops_score/idea_to_production_step.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ConversationalDevelopmentIndex +module DevOpsScore class IdeaToProductionStep attr_accessor :metric, :title, :features diff --git a/app/models/conversational_development_index/metric.rb b/app/models/dev_ops_score/metric.rb index b91123be87e..a9133128ce9 100644 --- a/app/models/conversational_development_index/metric.rb +++ b/app/models/dev_ops_score/metric.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ConversationalDevelopmentIndex +module DevOpsScore class Metric < ApplicationRecord include Presentable diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index 65e87bb08a7..686d06d3ee0 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -21,8 +21,8 @@ class DiffNote < Note validate :positions_complete validate :verify_supported - before_validation :set_line_code, if: :on_text? - after_save :keep_around_commits + before_validation :set_line_code, if: :on_text?, unless: :importing? + after_save :keep_around_commits, unless: :importing? after_commit :create_diff_file, on: :create def discussion_class(*) @@ -88,10 +88,6 @@ class DiffNote < Note line&.suggestible? end - def discussion_first_note? - self == discussion.first_note - end - def banzai_render_context(field) super.merge(suggestions_filter_enabled: true) end @@ -108,7 +104,7 @@ class DiffNote < Note end def should_create_diff_file? - on_text? && note_diff_file.nil? && discussion_first_note? + on_text? && note_diff_file.nil? && start_of_discussion? end def fetch_diff_file diff --git a/app/models/discussion.rb b/app/models/discussion.rb index b8525f7b135..d0a7db39a30 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -139,10 +139,6 @@ class Discussion false end - def new_discussion? - notes.length == 1 - end - def last_note @last_note ||= notes.last end diff --git a/app/models/environment.rb b/app/models/environment.rb index 327b1e594d7..b928dcb21a6 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -162,6 +162,10 @@ class Environment < ApplicationRecord stop_action&.play(current_user) end + def reset_auto_stop + update_column(:auto_stop_at, nil) + end + def actions_for(environment) return [] unless manual_actions @@ -193,11 +197,11 @@ class Environment < ApplicationRecord end def has_metrics? - available? && prometheus_adapter&.can_query? + available? && prometheus_adapter&.configured? end def metrics - prometheus_adapter.query(:environment, self) if has_metrics? + prometheus_adapter.query(:environment, self) if has_metrics? && prometheus_adapter.can_query? end def prometheus_status @@ -261,6 +265,17 @@ class Environment < ApplicationRecord end end + def auto_stop_in + auto_stop_at - Time.now if auto_stop_at + end + + def auto_stop_in=(value) + return unless value + return unless parsed_result = ChronicDuration.parse(value) + + self.auto_stop_at = parsed_result.seconds.from_now + end + private def generate_slug diff --git a/app/models/environment_status.rb b/app/models/environment_status.rb index d7dc64190d6..5fdb5af2d9b 100644 --- a/app/models/environment_status.rb +++ b/app/models/environment_status.rb @@ -20,6 +20,28 @@ class EnvironmentStatus build_environments_status(mr, user, mr.merge_pipeline) end + def self.for_deployed_merge_request(mr, user) + statuses = [] + + mr.recent_visible_deployments.each do |deploy| + env = deploy.environment + + next unless Ability.allowed?(user, :read_environment, env) + + statuses << + EnvironmentStatus.new(deploy.project, env, mr, deploy.sha) + end + + # Existing projects that used deployments prior to the introduction of + # explicitly linked merge requests won't have any data using this new + # approach, so we fall back to retrieving deployments based on CI pipelines. + if statuses.any? + statuses + else + after_merge_request(mr, user) + end + end + def initialize(project, environment, merge_request, sha) @project = project @environment = environment @@ -78,7 +100,7 @@ class EnvironmentStatus def self.build_environments_status(mr, user, pipeline) return [] unless pipeline - pipeline.environments.available.map do |environment| + pipeline.environments.includes(:project).available.map do |environment| next unless Ability.allowed?(user, :read_environment, environment) EnvironmentStatus.new(pipeline.project, environment, mr, pipeline.sha) diff --git a/app/models/epic.rb b/app/models/epic.rb index 46723462590..8222bbf9656 100644 --- a/app/models/epic.rb +++ b/app/models/epic.rb @@ -3,6 +3,10 @@ # Placeholder class for model that is implemented in EE # It reserves '&' as a reference prefix, but the table does not exists in CE class Epic < ApplicationRecord + include IgnorableColumns + + ignore_column :milestone_id, remove_after: '2019-12-15', remove_with: '12.7' + def self.link_reference_pattern nil end diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb index 2aa058a243f..6a9986e806b 100644 --- a/app/models/error_tracking/project_error_tracking_setting.rb +++ b/app/models/error_tracking/project_error_tracking_setting.rb @@ -5,6 +5,7 @@ module ErrorTracking include Gitlab::Utils::StrongMemoize include ReactiveCaching + SENTRY_API_ERROR_TYPE_BAD_REQUEST = 'bad_request_for_sentry_api' SENTRY_API_ERROR_TYPE_MISSING_KEYS = 'missing_keys_in_sentry_response' SENTRY_API_ERROR_TYPE_NON_20X_RESPONSE = 'non_20x_response_from_sentry' SENTRY_API_ERROR_INVALID_SIZE = 'invalid_size_of_sentry_response' @@ -85,7 +86,7 @@ module ErrorTracking end def list_sentry_projects - { projects: sentry_client.list_projects } + { projects: sentry_client.projects } end def issue_details(opts = {}) @@ -103,7 +104,7 @@ module ErrorTracking def calculate_reactive_cache(request, opts) case request when 'list_issues' - { issues: sentry_client.list_issues(**opts.symbolize_keys) } + sentry_client.list_issues(**opts.symbolize_keys) when 'issue_details' { issue: sentry_client.issue_details(**opts.symbolize_keys) @@ -119,6 +120,8 @@ module ErrorTracking { error: e.message, error_type: SENTRY_API_ERROR_TYPE_MISSING_KEYS } rescue Sentry::Client::ResponseInvalidSizeError => e { error: e.message, error_type: SENTRY_API_ERROR_INVALID_SIZE } + rescue Sentry::Client::BadRequestError => e + { error: e.message, error_type: SENTRY_API_ERROR_TYPE_BAD_REQUEST } end # http://HOST/api/0/projects/ORG/PROJECT diff --git a/app/models/group_milestone.rb b/app/models/group_milestone.rb index bfda603c3cb..87338512d99 100644 --- a/app/models/group_milestone.rb +++ b/app/models/group_milestone.rb @@ -41,4 +41,8 @@ class GroupMilestone < GlobalMilestone def legacy_group_milestone? true end + + def merge_requests_enabled? + true + end end diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index 65e3eaf31e7..a5f68831f34 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -2,6 +2,7 @@ class ProjectHook < WebHook include TriggerableHooks + include Presentable triggerable_hooks [ :push_hooks, @@ -18,6 +19,10 @@ class ProjectHook < WebHook belongs_to :project validates :project, presence: true + + def pluralized_name + _('Project Hooks') + end end ProjectHook.prepend_if_ee('EE::ProjectHook') diff --git a/app/models/hooks/service_hook.rb b/app/models/hooks/service_hook.rb index 8f305dd7c22..4caa45a13d4 100644 --- a/app/models/hooks/service_hook.rb +++ b/app/models/hooks/service_hook.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ServiceHook < WebHook + include Presentable + belongs_to :service validates :service, presence: true diff --git a/app/models/hooks/system_hook.rb b/app/models/hooks/system_hook.rb index 3d54d17e787..c8a0cc05912 100644 --- a/app/models/hooks/system_hook.rb +++ b/app/models/hooks/system_hook.rb @@ -20,4 +20,12 @@ class SystemHook < WebHook def allow_local_requests? Gitlab::CurrentSettings.allow_local_requests_from_system_hooks? end + + def pluralized_name + _('System Hooks') + end + + def help_path + 'system_hooks/system_hooks' + end end diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index e51b1c41059..dbd5a1b032a 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -37,4 +37,8 @@ class WebHook < ApplicationRecord def allow_local_requests? Gitlab::CurrentSettings.allow_local_requests_from_web_hooks_and_services? end + + def help_path + 'user/project/integrations/webhooks' + end end diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb index cfb1f3ec63b..df0e7b30f84 100644 --- a/app/models/hooks/web_hook_log.rb +++ b/app/models/hooks/web_hook_log.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true class WebHookLog < ApplicationRecord + include SafeUrl + include Presentable + belongs_to :web_hook serialize :request_headers, Hash # rubocop:disable Cop/ActiveRecordSerialize @@ -9,6 +12,8 @@ class WebHookLog < ApplicationRecord validates :web_hook, presence: true + before_save :obfuscate_basic_auth + def self.recent where('created_at >= ?', 2.days.ago.beginning_of_day) .order(created_at: :desc) @@ -17,4 +22,10 @@ class WebHookLog < ApplicationRecord def success? response_status =~ /^2/ end + + private + + def obfuscate_basic_auth + self.url = safe_url + end end diff --git a/app/models/import_failure.rb b/app/models/import_failure.rb new file mode 100644 index 00000000000..998572853d3 --- /dev/null +++ b/app/models/import_failure.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ImportFailure < ApplicationRecord + belongs_to :project + + validates :project, presence: true +end diff --git a/app/models/issue.rb b/app/models/issue.rb index 948cadc34e5..88df3baa809 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -14,6 +14,7 @@ class Issue < ApplicationRecord include TimeTrackable include ThrottledTouch include LabelEventable + include IgnorableColumns DueDateStruct = Struct.new(:title, :name).freeze NoDueDate = DueDateStruct.new('No Due Date', '0').freeze @@ -41,6 +42,10 @@ class Issue < ApplicationRecord has_many :issue_assignees has_many :assignees, class_name: "User", through: :issue_assignees has_many :zoom_meetings + has_many :user_mentions, class_name: "IssueUserMention" + has_one :sentry_issue + + accepts_nested_attributes_for :sentry_issue validates :project, presence: true @@ -60,13 +65,15 @@ class Issue < ApplicationRecord scope :order_closest_future_date, -> { reorder(Arel.sql('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC')) } scope :order_relative_position_asc, -> { reorder(::Gitlab::Database.nulls_last_order('relative_position', 'ASC')) } - scope :preload_associations, -> { preload(:labels, project: :namespace) } + scope :preload_associated_models, -> { preload(:labels, project: :namespace) } scope :with_api_entity_associations, -> { preload(:timelogs, :assignees, :author, :notes, :labels, project: [:route, { namespace: :route }] ) } scope :public_only, -> { where(confidential: false) } scope :confidential_only, -> { where(confidential: true) } - scope :counts_by_state, -> { reorder(nil).group(:state).count } + scope :counts_by_state, -> { reorder(nil).group(:state_id).count } + + ignore_column :state, remove_with: '12.7', remove_after: '2019-12-22' after_commit :expire_etag_cache after_save :ensure_metrics, unless: :imported? @@ -74,7 +81,7 @@ class Issue < ApplicationRecord attr_spammable :title, spam_title: true attr_spammable :description, spam_description: true - state_machine :state_id, initial: :opened do + state_machine :state_id, initial: :opened, initialize: false do event :close do transition [:opened] => :closed end @@ -235,7 +242,7 @@ class Issue < ApplicationRecord return false unless readable_by?(user) - user.full_private_access? || + user.can_read_all_resources? || ::Gitlab::ExternalAuthorization.access_allowed?( user, project.external_authorization_classification_label) end diff --git a/app/models/issue/metrics.rb b/app/models/issue/metrics.rb index 8010cbc3d78..d4e51dcfbca 100644 --- a/app/models/issue/metrics.rb +++ b/app/models/issue/metrics.rb @@ -3,6 +3,12 @@ class Issue::Metrics < ApplicationRecord belongs_to :issue + scope :for_issues, ->(issues) { where(issue: issues) } + scope :with_first_mention_not_earlier_than, -> (timestamp) { + where(first_mentioned_in_commit_at: nil) + .or(where(arel_table['first_mentioned_in_commit_at'].gteq(timestamp))) + } + def record! if issue.milestone_id.present? && self.first_associated_with_milestone_at.blank? self.first_associated_with_milestone_at = Time.now diff --git a/app/models/issue_user_mention.rb b/app/models/issue_user_mention.rb new file mode 100644 index 00000000000..3eadd580f7f --- /dev/null +++ b/app/models/issue_user_mention.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class IssueUserMention < UserMention + belongs_to :issue + belongs_to :note +end diff --git a/app/models/key.rb b/app/models/key.rb index ff601966c26..e549c59b58f 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -5,6 +5,9 @@ require 'digest/md5' class Key < ApplicationRecord include AfterCommitQueue include Sortable + include Sha256Attribute + + sha256_attribute :fingerprint_sha256 belongs_to :user @@ -34,6 +37,12 @@ class Key < ApplicationRecord after_destroy :post_destroy_hook after_destroy :refresh_user_cache + alias_attribute :fingerprint_md5, :fingerprint + + scope :preload_users, -> { preload(:user) } + scope :for_user, -> (user) { where(user: user) } + scope :order_last_used_at_desc, -> { reorder(::Gitlab::Database.nulls_last_order('last_used_at', 'DESC')) } + def self.regular_keys where(type: ['Key', nil]) end @@ -114,10 +123,12 @@ class Key < ApplicationRecord def generate_fingerprint self.fingerprint = nil + self.fingerprint_sha256 = nil return unless public_key.valid? - self.fingerprint = public_key.fingerprint + self.fingerprint_md5 = public_key.fingerprint + self.fingerprint_sha256 = public_key.fingerprint("SHA256").gsub("SHA256:", "") end def key_meets_restrictions diff --git a/app/models/list.rb b/app/models/list.rb index 13c42b55bf7..b2ba796e3dc 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -21,7 +21,7 @@ class List < ApplicationRecord scope :destroyable, -> { where(list_type: list_types.slice(*destroyable_types).values) } scope :movable, -> { where(list_type: list_types.slice(*movable_types).values) } - scope :preload_associations, -> { preload(:board, label: :priorities) } + scope :preload_associated_models, -> { preload(:board, label: :priorities) } scope :ordered, -> { order(:list_type, :position) } diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 7e1898e7142..2280c5280d5 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -17,6 +17,7 @@ class MergeRequest < ApplicationRecord include FromUnion include DeprecatedAssignee include ShaAttribute + include IgnorableColumns sha_attribute :squash_commit_sha @@ -26,8 +27,6 @@ class MergeRequest < ApplicationRecord SORTING_PREFERENCE_FIELD = :merge_requests_sort - prepend_if_ee('::EE::MergeRequest') # rubocop: disable Cop/InjectEnterpriseEditionModule - belongs_to :target_project, class_name: "Project" belongs_to :source_project, class_name: "Project" belongs_to :merge_user, class_name: "User" @@ -72,6 +71,15 @@ class MergeRequest < ApplicationRecord has_many :merge_request_assignees has_many :assignees, class_name: "User", through: :merge_request_assignees + has_many :user_mentions, class_name: "MergeRequestUserMention" + + has_many :deployment_merge_requests + + # These are deployments created after the merge request has been merged, and + # the merge request was tracked explicitly (instead of implicitly using a CI + # build). + has_many :deployments, + through: :deployment_merge_requests KNOWN_MERGE_PARAMS = [ :auto_merge_strategy, @@ -103,7 +111,7 @@ class MergeRequest < ApplicationRecord super + [:merged, :locked] end - state_machine :state_id, initial: :opened do + state_machine :state_id, initial: :opened, initialize: false do event :close do transition [:opened] => :closed end @@ -199,6 +207,9 @@ class MergeRequest < ApplicationRecord scope :by_milestone, ->(milestone) { where(milestone_id: milestone) } scope :of_projects, ->(ids) { where(target_project_id: ids) } scope :from_project, ->(project) { where(source_project_id: project.id) } + scope :from_and_to_forks, ->(project) do + where('source_project_id <> target_project_id AND (source_project_id = ? OR target_project_id = ?)', project.id, project.id) + end scope :merged, -> { with_state(:merged) } scope :closed_and_merged, -> { with_states(:closed, :merged) } scope :open_and_closed, -> { with_states(:opened, :closed) } @@ -228,7 +239,9 @@ class MergeRequest < ApplicationRecord with_state(:opened).where(auto_merge_enabled: true) end - after_save :keep_around_commit + ignore_column :state, remove_with: '12.7', remove_after: '2019-12-22' + + after_save :keep_around_commit, unless: :importing? alias_attribute :project, :target_project alias_attribute :project_id, :target_project_id @@ -241,6 +254,9 @@ class MergeRequest < ApplicationRecord alias_attribute :auto_merge_enabled, :merge_when_pipeline_succeeds alias_method :issuing_parent, :target_project + delegate :active?, to: :head_pipeline, prefix: true, allow_nil: true + delegate :success?, to: :actual_head_pipeline, prefix: true, allow_nil: true + RebaseLockTimeout = Class.new(StandardError) REBASE_LOCK_MESSAGE = _("Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later.") @@ -260,7 +276,7 @@ class MergeRequest < ApplicationRecord def self.recent_target_branches(limit: 100) group(:target_branch) .select(:target_branch) - .reorder('MAX(merge_requests.updated_at) DESC') + .reorder(arel_table[:updated_at].maximum.desc) .limit(limit) .pluck(:target_branch) end @@ -414,15 +430,6 @@ class MergeRequest < ApplicationRecord limit ? shas.take(limit) : shas end - # Returns true if there are commits that match at least one commit SHA. - def includes_any_commits?(shas) - if persisted? - merge_request_diff.commits_by_shas(shas).exists? - else - (commit_shas & shas).present? - end - end - def supports_suggestion? true end @@ -1060,7 +1067,7 @@ class MergeRequest < ApplicationRecord # Returns the oldest multi-line commit message, or the MR title if none found def default_squash_commit_message strong_memoize(:default_squash_commit_message) do - commits.without_merge_commits.reverse.find(&:description?)&.safe_message || title + recent_commits.without_merge_commits.reverse_each.find(&:description?)&.safe_message || title end end @@ -1143,26 +1150,6 @@ class MergeRequest < ApplicationRecord actual_head_pipeline.environments end - def state_human_name - if merged? - "Merged" - elsif closed? - "Closed" - else - "Open" - end - end - - def state_icon_name - if merged? - "git-merge" - elsif closed? - "close" - else - "issue-open-m" - end - end - def fetch_ref! target_project.repository.fetch_source_branch!(source_project.repository, source_branch, ref_path) end @@ -1239,16 +1226,8 @@ class MergeRequest < ApplicationRecord end def all_pipelines - return Ci::Pipeline.none unless source_project - - shas = all_commit_shas - strong_memoize(:all_pipelines) do - Ci::Pipeline.from_union( - [source_project.ci_pipelines.merge_request_pipelines(self, shas), - source_project.ci_pipelines.detached_merge_request_pipelines(self, shas), - source_project.ci_pipelines.triggered_for_branch(source_branch).for_sha(shas)], - remove_duplicates: false).sort_by_merge_request_pipelines + MergeRequest::Pipelines.new(self).all end end @@ -1444,6 +1423,12 @@ class MergeRequest < ApplicationRecord true end + def pipeline_coverage_delta + if base_pipeline&.coverage && head_pipeline&.coverage + '%.2f' % (head_pipeline.coverage.to_f - base_pipeline.coverage.to_f) + end + end + def base_pipeline @base_pipeline ||= project.ci_pipelines .order(id: :desc) @@ -1499,6 +1484,14 @@ class MergeRequest < ApplicationRecord all_pipelines.for_sha_or_source_sha(diff_head_sha).first end + def etag_caching_enabled? + true + end + + def recent_visible_deployments + deployments.visible.includes(:environment).order(id: :desc).limit(10) + end + private def with_rebase_lock @@ -1521,7 +1514,7 @@ class MergeRequest < ApplicationRecord end end rescue ActiveRecord::LockWaitTimeout => e - Gitlab::Sentry.track_acceptable_exception(e) + Gitlab::ErrorTracking.track_exception(e) raise RebaseLockTimeout, REBASE_LOCK_MESSAGE end @@ -1543,3 +1536,5 @@ class MergeRequest < ApplicationRecord Gitlab::EtagCaching::Store.new.touch(key) end end + +MergeRequest.prepend_if_ee('::EE::MergeRequest') diff --git a/app/models/merge_request/pipelines.rb b/app/models/merge_request/pipelines.rb new file mode 100644 index 00000000000..c32f29a9304 --- /dev/null +++ b/app/models/merge_request/pipelines.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +# A state object to centralize logic related to merge request pipelines +class MergeRequest::Pipelines + include Gitlab::Utils::StrongMemoize + + EVENT = 'merge_request_event' + + def initialize(merge_request) + @merge_request = merge_request + end + + attr_reader :merge_request + + delegate :commit_shas, :source_project, :source_branch, to: :merge_request + + def all + strong_memoize(:all_pipelines) do + next Ci::Pipeline.none unless source_project + + pipelines = + if merge_request.persisted? + pipelines_using_cte + else + triggered_for_branch.for_sha(commit_shas) + end + + sort(pipelines) + end + end + + private + + def pipelines_using_cte + cte = Gitlab::SQL::CTE.new(:shas, merge_request.all_commits.select(:sha)) + + source_pipelines_join = cte.table[:sha].eq(Ci::Pipeline.arel_table[:source_sha]) + source_pipelines = filter_by(triggered_by_merge_request, cte, source_pipelines_join) + detached_pipelines = filter_by_sha(triggered_by_merge_request, cte) + pipelines_for_branch = filter_by_sha(triggered_for_branch, cte) + + Ci::Pipeline.with(cte.to_arel) + .from_union([source_pipelines, detached_pipelines, pipelines_for_branch]) + end + + def filter_by_sha(pipelines, cte) + hex = Arel::Nodes::SqlLiteral.new("'hex'") + string_sha = Arel::Nodes::NamedFunction.new('encode', [cte.table[:sha], hex]) + join_condition = string_sha.eq(Ci::Pipeline.arel_table[:sha]) + + filter_by(pipelines, cte, join_condition) + end + + def filter_by(pipelines, cte, join_condition) + shas_table = + Ci::Pipeline.arel_table + .join(cte.table, Arel::Nodes::InnerJoin) + .on(join_condition) + .join_sources + + pipelines.joins(shas_table) + end + + def triggered_by_merge_request + source_project.ci_pipelines + .where(source: :merge_request_event, merge_request: merge_request) + end + + def triggered_for_branch + source_project.ci_pipelines + .where(source: branch_pipeline_sources, ref: source_branch, tag: false) + end + + def branch_pipeline_sources + strong_memoize(:branch_pipeline_sources) do + Ci::Pipeline.sources.reject { |source| source == EVENT }.values + end + end + + def sort(pipelines) + sql = 'CASE ci_pipelines.source WHEN (?) THEN 0 ELSE 1 END, ci_pipelines.id DESC' + query = ApplicationRecord.send(:sanitize_sql_array, [sql, Ci::Pipeline.sources[:merge_request_event]]) # rubocop:disable GitlabSecurity/PublicSend + + pipelines.order(Arel.sql(query)) + end +end diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 70ce4df5678..71a344e69e3 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -10,6 +10,7 @@ class MergeRequestDiff < ApplicationRecord # Don't display more than 100 commits at once COMMITS_SAFE_SIZE = 100 + BATCH_SIZE = 1000 # Applies to closed or merged MRs when determining whether to migrate their # diffs to external storage @@ -49,13 +50,14 @@ class MergeRequestDiff < ApplicationRecord scope :by_commit_sha, ->(sha) do joins(:merge_request_diff_commits).where(merge_request_diff_commits: { sha: sha }).reorder(nil) end + scope :has_diff_files, -> { where(id: MergeRequestDiffFile.select(:merge_request_diff_id)) } scope :by_project_id, -> (project_id) do joins(:merge_request).where(merge_requests: { target_project_id: project_id }) end scope :recent, -> { order(id: :desc).limit(100) } - scope :files_in_database, -> { where(stored_externally: [false, nil]) } + scope :files_in_database, -> { has_diff_files.where(stored_externally: [false, nil]) } scope :not_latest_diffs, -> do merge_requests = MergeRequest.arel_table @@ -162,7 +164,7 @@ class MergeRequestDiff < ApplicationRecord # hooks that run when an attribute was changed are run twice. reset - keep_around_commits + keep_around_commits unless importing? end def set_as_latest_diff @@ -253,10 +255,14 @@ class MergeRequestDiff < ApplicationRecord merge_request_diff_commits.limit(limit).pluck(:sha) end - def commits_by_shas(shas) - return MergeRequestDiffCommit.none unless shas.present? + def includes_any_commits?(shas) + return false if shas.blank? - merge_request_diff_commits.where(sha: shas) + # when the number of shas is huge (1000+) we don't want + # to pass them all as an SQL param, let's pass them in batches + shas.each_slice(BATCH_SIZE).any? do |batched_shas| + merge_request_diff_commits.where(sha: batched_shas).exists? + end end def diff_refs=(new_diff_refs) @@ -303,20 +309,25 @@ class MergeRequestDiff < ApplicationRecord end def diffs_in_batch(batch_page, batch_size, diff_options:) - Gitlab::Diff::FileCollection::MergeRequestDiffBatch.new(self, - batch_page, - batch_size, - diff_options: diff_options) + fetching_repository_diffs(diff_options) do |comparison| + if comparison + comparison.diffs_in_batch(batch_page, batch_size, diff_options: diff_options) + else + diffs_in_batch_collection(batch_page, batch_size, diff_options: diff_options) + end + end end def diffs(diff_options = nil) - if without_files? && comparison = diff_refs&.compare_in(project) + fetching_repository_diffs(diff_options) do |comparison| # It should fetch the repository when diffs are cleaned by the system. # We don't keep these for storage overload purposes. # See https://gitlab.com/gitlab-org/gitlab-foss/issues/37639 - comparison.diffs(diff_options) - else - diffs_collection(diff_options) + if comparison + comparison.diffs(diff_options) + else + diffs_collection(diff_options) + end end end @@ -424,6 +435,13 @@ class MergeRequestDiff < ApplicationRecord private + def diffs_in_batch_collection(batch_page, batch_size, diff_options:) + Gitlab::Diff::FileCollection::MergeRequestDiffBatch.new(self, + batch_page, + batch_size, + diff_options: diff_options) + end + def encode_in_base64?(diff_text) (diff_text.encoding == Encoding::BINARY && !diff_text.ascii_only?) || diff_text.include?("\0") @@ -481,6 +499,25 @@ class MergeRequestDiff < ApplicationRecord end end + # Yields the block with the repository Compare object if it should + # fetch diffs from the repository instead DB. + def fetching_repository_diffs(diff_options) + return unless block_given? + + diff_options ||= {} + + # Can be read as: fetch the persisted diffs if yielded without the + # Compare object. + return yield unless without_files? || diff_options[:ignore_whitespace_change] + return yield unless diff_refs&.complete? + + comparison = diff_refs.compare_in(repository.project) + + return yield unless comparison + + yield(comparison) + end + def use_external_diff? return false unless has_attribute?(:external_diff) return false unless Gitlab.config.external_diffs.enabled diff --git a/app/models/merge_request_user_mention.rb b/app/models/merge_request_user_mention.rb new file mode 100644 index 00000000000..222d9c1aa8c --- /dev/null +++ b/app/models/merge_request_user_mention.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class MergeRequestUserMention < UserMention + belongs_to :merge_request + belongs_to :note +end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index d0be54eed02..987373aaf1b 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -274,6 +274,16 @@ class Milestone < ApplicationRecord project_id.present? end + def merge_requests_enabled? + if group_milestone? + # Assume that groups have at least one project with merge requests enabled. + # Otherwise, we would need to load all of the projects from the database. + true + elsif project_milestone? + project&.merge_requests_enabled? + end + end + private # Milestone titles must be unique across project milestones and group milestones @@ -331,6 +341,6 @@ class Milestone < ApplicationRecord end def issues_finder_params - { project_id: project_id, group_id: group_id }.compact + { project_id: project_id, group_id: group_id, include_subgroups: group_id.present? }.compact end end diff --git a/app/models/milestone_release.rb b/app/models/milestone_release.rb index f7127df339d..713c8ef7b94 100644 --- a/app/models/milestone_release.rb +++ b/app/models/milestone_release.rb @@ -6,9 +6,6 @@ class MilestoneRelease < ApplicationRecord validate :same_project_between_milestone_and_release - # Keep until 2019-11-29 - self.ignored_columns += %i[id] - private def same_project_between_milestone_and_release diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 5663ebf8ba1..d5a7c172fec 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -123,8 +123,10 @@ class Namespace < ApplicationRecord def find_by_pages_host(host) gitlab_host = "." + Settings.pages.host.downcase - name = host.downcase.delete_suffix(gitlab_host) + host = host.downcase + return unless host.ends_with?(gitlab_host) + name = host.delete_suffix(gitlab_host) Namespace.find_by_full_path(name) end end diff --git a/app/models/note.rb b/app/models/note.rb index 493132e30cc..cfa7ba98081 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -155,9 +155,9 @@ class Note < ApplicationRecord after_initialize :ensure_discussion_id before_validation :nullify_blank_type, :nullify_blank_line_code before_validation :set_discussion_id, on: :create - after_save :keep_around_commit, if: :for_project_noteable? - after_save :expire_etag_cache - after_save :touch_noteable + after_save :keep_around_commit, if: :for_project_noteable?, unless: :importing? + after_save :expire_etag_cache, unless: :importing? + after_save :touch_noteable, unless: :importing? after_destroy :expire_etag_cache class << self @@ -413,6 +413,10 @@ class Note < ApplicationRecord full_discussion || to_discussion end + def start_of_discussion? + discussion.first_note == self + end + def part_of_discussion? !to_discussion.individual_note? end @@ -495,8 +499,18 @@ class Note < ApplicationRecord project end + def user_mentions + noteable.user_mentions.where(note: self) + end + private + # Using this method followed by a call to `save` may result in ActiveRecord::RecordNotUnique exception + # in a multithreaded environment. Make sure to use it within a `safe_ensure_unique` block. + def model_user_mention + user_mentions.first_or_initialize + end + def system_note_viewable_by?(user) return true unless system_note_metadata diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index 3869d86b667..dd2cafd9a35 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -6,6 +6,7 @@ class PagesDomain < ApplicationRecord SSL_RENEWAL_THRESHOLD = 30.days.freeze enum certificate_source: { user_provided: 0, gitlab_provided: 1 }, _prefix: :certificate + enum domain_type: { instance: 0, group: 1, project: 2 }, _prefix: :domain_type belongs_to :project has_many :acme_orders, class_name: "PagesDomainAcmeOrder" @@ -25,6 +26,8 @@ class PagesDomain < ApplicationRecord validate :validate_intermediates, if: ->(domain) { domain.certificate.present? && domain.certificate_changed? } default_value_for(:auto_ssl_enabled, allow_nil: false) { ::Gitlab::LetsEncrypt.enabled? } + default_value_for :domain_type, allow_nil: false, value: :project + default_value_for :wildcard, allow_nil: false, value: false attr_encrypted :key, mode: :per_attribute_iv_and_salt, @@ -217,6 +220,8 @@ class PagesDomain < ApplicationRecord # rubocop: disable CodeReuse/ServiceClass def update_daemon + return if domain_type_instance? + ::Projects::UpdatePagesConfigurationService.new(project).execute end # rubocop: enable CodeReuse/ServiceClass diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index 7ae431eaad7..af079f7ebc4 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -3,6 +3,7 @@ class PersonalAccessToken < ApplicationRecord include Expirable include TokenAuthenticatable + include Sortable add_authentication_token_field :token, digest: true @@ -16,9 +17,12 @@ class PersonalAccessToken < ApplicationRecord before_save :ensure_token scope :active, -> { where("revoked = false AND (expires_at >= NOW() OR expires_at IS NULL)") } + scope :expiring_and_not_notified, ->(date) { where(["revoked = false AND expire_notification_delivered = false AND expires_at >= NOW() AND expires_at <= ?", date]) } scope :inactive, -> { where("revoked = true OR expires_at < NOW()") } scope :with_impersonation, -> { where(impersonation: true) } scope :without_impersonation, -> { where(impersonation: false) } + scope :for_user, -> (user) { where(user: user) } + scope :preload_users, -> { preload(:user) } validates :scopes, presence: true validate :validate_scopes @@ -70,3 +74,5 @@ class PersonalAccessToken < ApplicationRecord "gitlab:personal_access_token:#{user_id}" end end + +PersonalAccessToken.prepend_if_ee('EE::PersonalAccessToken') diff --git a/app/models/project.rb b/app/models/project.rb index 7ae4e2a4cd7..cfdcdbed502 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -31,6 +31,7 @@ class Project < ApplicationRecord include FeatureGate include OptionallySearch include FromUnion + include IgnorableColumns extend Gitlab::Cache::RequestCache extend Gitlab::ConfigHelper @@ -62,23 +63,9 @@ class Project < ApplicationRecord cache_markdown_field :description, pipeline: :description - delegate :feature_available?, :builds_enabled?, :wiki_enabled?, :merge_requests_enabled?, - :issues_enabled?, :pages_enabled?, :public_pages?, :private_pages?, - :merge_requests_access_level, :issues_access_level, :wiki_access_level, - :snippets_access_level, :builds_access_level, :repository_access_level, - to: :project_feature, allow_nil: true - - delegate :base_dir, :disk_path, to: :storage - - delegate :scheduled?, :started?, :in_progress?, - :failed?, :finished?, - prefix: :import, to: :import_state, allow_nil: true - - delegate :no_import?, to: :import_state, allow_nil: true - # TODO: remove once GitLab 12.5 is released # https://gitlab.com/gitlab-org/gitlab/issues/34638 - self.ignored_columns += %i[merge_requests_require_code_owner_approval] + ignore_column :merge_requests_require_code_owner_approval, remove_after: '2019-12-01', remove_with: '12.6' default_value_for :archived, false default_value_for :resolve_outdated_diff_discussions, false @@ -110,8 +97,11 @@ class Project < ApplicationRecord unless: :ci_cd_settings, if: proc { ProjectCiCdSetting.available? } + after_create :create_container_expiration_policy, + unless: :container_expiration_policy + after_create :create_pages_metadatum, - unless: :pages_metadatum + unless: :pages_metadatum after_create :set_timestamps_for_create after_update :update_forks_visibility_level @@ -183,6 +173,7 @@ class Project < ApplicationRecord has_one :microsoft_teams_service has_one :packagist_service has_one :hangouts_chat_service + has_one :unify_circuit_service has_one :root_of_fork_network, foreign_key: 'root_project_id', @@ -193,6 +184,7 @@ class Project < ApplicationRecord has_one :forked_from_project, through: :fork_network_member has_many :forked_to_members, class_name: 'ForkNetworkMember', foreign_key: 'forked_from_project_id' has_many :forks, through: :forked_to_members, source: :project, inverse_of: :forked_from_project + has_many :fork_network_projects, through: :fork_network, source: :projects has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -259,6 +251,7 @@ class Project < ApplicationRecord # which is not managed by the DB. Hence we're still using dependent: :destroy # here. has_many :container_repositories, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_one :container_expiration_policy, inverse_of: :project has_many :commit_statuses # The relation :all_pipelines is intended to be used when we want to get the @@ -309,11 +302,14 @@ class Project < ApplicationRecord has_one :pages_metadatum, class_name: 'ProjectPagesMetadatum', inverse_of: :project + has_many :import_failures, inverse_of: :project + accepts_nested_attributes_for :variables, allow_destroy: true accepts_nested_attributes_for :project_feature, update_only: true accepts_nested_attributes_for :import_data accepts_nested_attributes_for :auto_devops, update_only: true accepts_nested_attributes_for :ci_cd_settings, update_only: true + accepts_nested_attributes_for :container_expiration_policy, update_only: true accepts_nested_attributes_for :remote_mirrors, allow_destroy: true, @@ -323,13 +319,22 @@ class Project < ApplicationRecord accepts_nested_attributes_for :metrics_setting, update_only: true, allow_destroy: true accepts_nested_attributes_for :grafana_integration, update_only: true, allow_destroy: true + delegate :feature_available?, :builds_enabled?, :wiki_enabled?, :merge_requests_enabled?, + :issues_enabled?, :pages_enabled?, :public_pages?, :private_pages?, + :merge_requests_access_level, :issues_access_level, :wiki_access_level, + :snippets_access_level, :builds_access_level, :repository_access_level, + to: :project_feature, allow_nil: true + delegate :scheduled?, :started?, :in_progress?, :failed?, :finished?, + prefix: :import, to: :import_state, allow_nil: true + delegate :base_dir, :disk_path, to: :storage + delegate :no_import?, to: :import_state, allow_nil: true delegate :name, to: :owner, allow_nil: true, prefix: true delegate :members, to: :team, prefix: true delegate :add_user, :add_users, to: :team delegate :add_guest, :add_reporter, :add_developer, :add_maintainer, :add_role, to: :team delegate :add_master, to: :team # @deprecated delegate :group_runners_enabled, :group_runners_enabled=, :group_runners_enabled?, to: :ci_cd_settings - delegate :root_ancestor, to: :namespace, allow_nil: true + delegate :root_ancestor, :actual_limits, to: :namespace, allow_nil: true delegate :last_pipeline, to: :commit, allow_nil: true delegate :external_dashboard_url, to: :metrics_setting, allow_nil: true, prefix: true delegate :default_git_depth, :default_git_depth=, to: :ci_cd_settings, prefix: :ci @@ -374,9 +379,17 @@ class Project < ApplicationRecord scope :pending_delete, -> { where(pending_delete: true) } scope :without_deleted, -> { where(pending_delete: false) } - scope :with_storage_feature, ->(feature) { where('storage_version >= :version', version: HASHED_STORAGE_FEATURES[feature]) } - scope :without_storage_feature, ->(feature) { where('storage_version < :version OR storage_version IS NULL', version: HASHED_STORAGE_FEATURES[feature]) } - scope :with_unmigrated_storage, -> { where('storage_version < :version OR storage_version IS NULL', version: LATEST_STORAGE_VERSION) } + scope :with_storage_feature, ->(feature) do + where(arel_table[:storage_version].gteq(HASHED_STORAGE_FEATURES[feature])) + end + scope :without_storage_feature, ->(feature) do + where(arel_table[:storage_version].lt(HASHED_STORAGE_FEATURES[feature]) + .or(arel_table[:storage_version].eq(nil))) + end + scope :with_unmigrated_storage, -> do + where(arel_table[:storage_version].lt(LATEST_STORAGE_VERSION) + .or(arel_table[:storage_version].eq(nil))) + end # last_activity_at is throttled every minute, but last_repository_updated_at is updated with every push scope :sorted_by_activity, -> { reorder(Arel.sql("GREATEST(COALESCE(last_activity_at, '1970-01-01'), COALESCE(last_repository_updated_at, '1970-01-01')) DESC")) } @@ -395,7 +408,9 @@ class Project < ApplicationRecord scope :for_milestones, ->(ids) { joins(:milestones).where('milestones.id' => ids).distinct } scope :with_push, -> { joins(:events).where('events.action = ?', Event::PUSHED) } scope :with_project_feature, -> { joins('LEFT JOIN project_features ON projects.id = project_features.project_id') } + scope :inc_routes, -> { includes(:route, namespace: :route) } scope :with_statistics, -> { includes(:statistics) } + scope :with_service, ->(service) { joins(service).eager_load(service) } scope :with_shared_runners, -> { where(shared_runners_enabled: true) } scope :with_container_registry, -> { where(container_registry_enabled: true) } scope :inside_path, ->(path) do @@ -435,6 +450,7 @@ class Project < ApplicationRecord scope :with_merge_requests_available_for_user, ->(current_user) { with_feature_available_for_user(:merge_requests, current_user) } scope :with_merge_requests_enabled, -> { with_feature_enabled(:merge_requests) } scope :with_remote_mirrors, -> { joins(:remote_mirrors).where(remote_mirrors: { enabled: true }).distinct } + scope :with_limit, -> (maximum) { limit(maximum) } scope :with_group_runners_enabled, -> do joins(:ci_cd_settings) @@ -543,7 +559,11 @@ class Project < ApplicationRecord # # query - The search query as a String. def search(query) - fuzzy_search(query, [:path, :name, :description]) + if Feature.enabled?(:project_search_by_full_path, default_enabled: true) + joins(:route).fuzzy_search(query, [Route.arel_table[:path], :name, :description]) + else + fuzzy_search(query, [:path, :name, :description]) + end end def search_by_title(query) @@ -720,6 +740,10 @@ class Project < ApplicationRecord Feature.enabled?(:project_daily_statistics, self, default_enabled: true) end + def unlink_forks_upon_visibility_decrease_enabled? + Feature.enabled?(:unlink_fork_network_upon_visibility_decrease, self, default_enabled: true) + end + def empty_repo? repository.empty? end @@ -1239,8 +1263,9 @@ class Project < ApplicationRecord def all_clusters group_clusters = Clusters::Cluster.joins(:groups).where(cluster_groups: { group_id: ancestors_upto } ) + instance_clusters = Clusters::Cluster.instance_type - Clusters::Cluster.from_union([clusters, group_clusters]) + Clusters::Cluster.from_union([clusters, group_clusters, instance_clusters]) end def items_for(entity) @@ -1538,6 +1563,7 @@ class Project < ApplicationRecord # update visibility_level of forks def update_forks_visibility_level + return if unlink_forks_upon_visibility_decrease_enabled? return unless visibility_level < visibility_level_before_last_save forks.each do |forked_project| @@ -1557,7 +1583,9 @@ class Project < ApplicationRecord end def wiki - @wiki ||= ProjectWiki.new(self, self.owner) + strong_memoize(:wiki) do + ProjectWiki.new(self, self.owner) + end end def jira_tracker_active? @@ -1777,7 +1805,6 @@ class Project < ApplicationRecord InternalId.flush_records!(project: self) import_state.finish - import_state.remove_jid update_project_counter_caches after_create_default_branch join_pool_repository @@ -1872,9 +1899,18 @@ class Project < ApplicationRecord end def predefined_variables - visibility = Gitlab::VisibilityLevel.string_level(visibility_level) + Gitlab::Ci::Variables::Collection.new + .concat(predefined_ci_server_variables) + .concat(predefined_project_variables) + .concat(pages_variables) + .concat(container_registry_variables) + .concat(auto_devops_variables) + .concat(api_variables) + end + def predefined_project_variables Gitlab::Ci::Variables::Collection.new + .append(key: 'GITLAB_FEATURES', value: licensed_features.join(',')) .append(key: 'CI_PROJECT_ID', value: id.to_s) .append(key: 'CI_PROJECT_NAME', value: path) .append(key: 'CI_PROJECT_TITLE', value: title) @@ -1882,16 +1918,28 @@ class Project < ApplicationRecord .append(key: 'CI_PROJECT_PATH_SLUG', value: full_path_slug) .append(key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path) .append(key: 'CI_PROJECT_URL', value: web_url) - .append(key: 'CI_PROJECT_VISIBILITY', value: visibility) + .append(key: 'CI_PROJECT_VISIBILITY', value: Gitlab::VisibilityLevel.string_level(visibility_level)) .append(key: 'CI_PROJECT_REPOSITORY_LANGUAGES', value: repository_languages.map(&:name).join(',').downcase) - .concat(pages_variables) - .concat(container_registry_variables) - .concat(auto_devops_variables) - .concat(api_variables) + .append(key: 'CI_DEFAULT_BRANCH', value: default_branch) + end + + def predefined_ci_server_variables + Gitlab::Ci::Variables::Collection.new + .append(key: 'CI', value: 'true') + .append(key: 'GITLAB_CI', value: 'true') + .append(key: 'CI_SERVER_HOST', value: Gitlab.config.gitlab.host) + .append(key: 'CI_SERVER_NAME', value: 'GitLab') + .append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION) + .append(key: 'CI_SERVER_VERSION_MAJOR', value: Gitlab.version_info.major.to_s) + .append(key: 'CI_SERVER_VERSION_MINOR', value: Gitlab.version_info.minor.to_s) + .append(key: 'CI_SERVER_VERSION_PATCH', value: Gitlab.version_info.patch.to_s) + .append(key: 'CI_SERVER_REVISION', value: Gitlab.revision) end def pages_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| + break unless pages_enabled? + variables.append(key: 'CI_PAGES_DOMAIN', value: Gitlab.config.pages.host) variables.append(key: 'CI_PAGES_URL', value: pages_url) end @@ -1957,12 +2005,16 @@ class Project < ApplicationRecord end end - def deployment_variables(environment:) + def deployment_variables(environment:, kubernetes_namespace: nil) platform = deployment_platform(environment: environment) return [] unless platform.present? - platform.predefined_variables(project: self, environment_name: environment) + platform.predefined_variables( + project: self, + environment_name: environment, + kubernetes_namespace: kubernetes_namespace + ) end def auto_devops_variables @@ -2027,10 +2079,16 @@ class Project < ApplicationRecord end def default_merge_request_target - if forked_from_project&.merge_requests_enabled? - forked_from_project - else + return self unless forked_from_project + return self unless forked_from_project.merge_requests_enabled? + + # When our current visibility is more restrictive than the source project, + # (e.g., the fork is `private` but the parent is `public`), target the less + # permissive project + if visibility_level_value < forked_from_project.visibility_level_value self + else + forked_from_project end end @@ -2227,12 +2285,13 @@ class Project < ApplicationRecord # Git objects are only poolable when the project is or has: # - Hashed storage -> The object pool will have a remote to its members, using relative paths. # If the repository path changes we would have to update the remote. - # - Public -> User will be able to fetch Git objects that might not exist - # in their own repository. + # - not private -> The visibility level or repository access level has to be greater than private + # to prevent fetching objects that might not exist # - Repository -> Else the disk path will be empty, and there's nothing to pool def git_objects_poolable? hashed_storage?(:repository) && - public? && + visibility_level > Gitlab::VisibilityLevel::PRIVATE && + repository_access_level > ProjectFeature::PRIVATE && repository_exists? && Gitlab::CurrentSettings.hashed_storage_enabled end diff --git a/app/models/project_auto_devops.rb b/app/models/project_auto_devops.rb index e11d0c48b4b..275fe81583f 100644 --- a/app/models/project_auto_devops.rb +++ b/app/models/project_auto_devops.rb @@ -16,6 +16,7 @@ class ProjectAutoDevops < ApplicationRecord def predefined_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'AUTO_DEVOPS_EXPLICITLY_ENABLED', value: '1') if enabled? variables.concat(deployment_strategy_default_variables) end end diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb index d089a004d3d..b292d39dae7 100644 --- a/app/models/project_ci_cd_setting.rb +++ b/app/models/project_ci_cd_setting.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true class ProjectCiCdSetting < ApplicationRecord - # TODO: remove once GitLab 12.7 is released + include IgnorableColumns # https://gitlab.com/gitlab-org/gitlab/issues/36651 - self.ignored_columns += %i[merge_trains_enabled] + ignore_column :merge_trains_enabled, remove_with: '12.7', remove_after: '2019-12-22' + belongs_to :project, inverse_of: :ci_cd_settings # The version of the schema that first introduced this model/table. diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index caa65d32c86..4973c7761c1 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -186,7 +186,7 @@ class ProjectFeature < ApplicationRecord def team_access?(user, feature) return unless user - return true if user.full_private_access? + return true if user.can_read_all_resources? project.team.member?(user, ProjectFeature.required_minimum_access_level(feature)) end diff --git a/app/models/project_import_state.rb b/app/models/project_import_state.rb index bff00816e15..b79e3554926 100644 --- a/app/models/project_import_state.rb +++ b/app/models/project_import_state.rb @@ -42,6 +42,14 @@ class ProjectImportState < ApplicationRecord end end + after_transition any => :finished do |state, _| + if state.jid.present? + Gitlab::SidekiqStatus.unset(state.jid) + + state.update_column(:jid, nil) + end + end + after_transition started: :finished do |state, _| project = state.project @@ -81,14 +89,6 @@ class ProjectImportState < ApplicationRecord status == 'started' && project.import? end - def remove_jid - return unless jid - - Gitlab::SidekiqStatus.unset(jid) - - update_column(:jid, nil) - end - # Refreshes the expiration time of the associated import job ID. # # This method can be used by asynchronous importers to refresh the status, diff --git a/app/models/project_services/asana_service.rb b/app/models/project_services/asana_service.rb index 757b2f17fb9..c4fcdcc05c5 100644 --- a/app/models/project_services/asana_service.rb +++ b/app/models/project_services/asana_service.rb @@ -24,7 +24,7 @@ get the commit comment added to it. You can also close a task with a message containing: `fix #123456`. You can create a Personal Access Token here: -http://app.asana.com/-/account_api' +https://app.asana.com/0/developer-console' end def self.to_param diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index ba61810e26f..128cbc6fa82 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -32,6 +32,10 @@ class JiraService < IssueTrackerService %w(commit merge_request) end + def self.supported_event_actions + %w(comment) + end + # {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1 def self.reference_pattern(only_long: true) @reference_pattern ||= /(?<issue>\b#{Gitlab::Regex.jira_issue_key_regex})/ @@ -268,19 +272,27 @@ class JiraService < IssueTrackerService return unless client_url.present? jira_request do - remote_link = find_remote_link(issue, remote_link_props[:object][:url]) - if remote_link - remote_link.save!(remote_link_props) - elsif issue.comments.build.save!(body: message) - new_remote_link = issue.remotelink.build - new_remote_link.save!(remote_link_props) - end + create_issue_link(issue, remote_link_props) + create_issue_comment(issue, message) log_info("Successfully posted", client_url: client_url) "SUCCESS: Successfully posted to #{client_url}." end end + def create_issue_link(issue, remote_link_props) + remote_link = find_remote_link(issue, remote_link_props[:object][:url]) + remote_link ||= issue.remotelink.build + + remote_link.save!(remote_link_props) + end + + def create_issue_comment(issue, message) + return unless comment_on_event_enabled + + issue.comments.build.save!(body: message) + end + def find_remote_link(issue, url) links = jira_request { issue.remotelink.all } return unless links diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index a0273fe0e5a..3d5967de41e 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -22,6 +22,8 @@ class PrometheusService < MonitoringService after_save :clear_reactive_cache! + after_commit :track_events + def initialize_properties if properties.nil? self.properties = {} @@ -86,13 +88,17 @@ class PrometheusService < MonitoringService return false if template? return false unless project - project.clusters.enabled.any? { |cluster| cluster.application_prometheus_available? } + project.all_clusters.enabled.any? { |cluster| cluster.application_prometheus_available? } end def allow_local_api_url? self_monitoring_project? && internal_prometheus_url? end + def configured? + should_return_client? + end + private def self_monitoring_project? @@ -116,4 +122,22 @@ class PrometheusService < MonitoringService true end + + def track_events + if enabled_manual_prometheus? + Gitlab::Tracking.event('cluster:services:prometheus', 'enabled_manual_prometheus') + elsif disabled_manual_prometheus? + Gitlab::Tracking.event('cluster:services:prometheus', 'disabled_manual_prometheus') + end + + true + end + + def enabled_manual_prometheus? + manual_configuration_changed? && manual_configuration? + end + + def disabled_manual_prometheus? + manual_configuration_changed? && !manual_configuration? + end end diff --git a/app/models/project_services/unify_circuit_service.rb b/app/models/project_services/unify_circuit_service.rb new file mode 100644 index 00000000000..06f2d10f83b --- /dev/null +++ b/app/models/project_services/unify_circuit_service.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class UnifyCircuitService < ChatNotificationService + def title + 'Unify Circuit' + end + + def description + 'Receive event notifications in Unify Circuit' + end + + def self.to_param + 'unify_circuit' + end + + def help + 'This service sends notifications about projects events to a Unify Circuit conversation.<br /> + To set up this service: + <ol> + <li><a href="https://www.circuit.com/unifyportalfaqdetail?articleId=164448">Set up an incoming webhook for your conversation</a>. All notifications will come to this conversation.</li> + <li>Paste the <strong>Webhook URL</strong> into the field below.</li> + <li>Select events below to enable notifications.</li> + </ol>' + end + + def event_field(event) + end + + def default_channel_placeholder + end + + def self.supported_events + %w[push issue confidential_issue merge_request note confidential_note tag_push + pipeline wiki_page] + end + + def default_fields + [ + { type: 'text', name: 'webhook', placeholder: "e.g. https://circuit.com/rest/v2/webhooks/incoming/…", required: true }, + { type: 'checkbox', name: 'notify_only_broken_pipelines' }, + { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES } + ] + end + + private + + def notify(message, opts) + response = Gitlab::HTTP.post(webhook, body: { + subject: message.project_name, + text: message.pretext, + markdown: true + }.to_json) + + response if response.success? + end + + def custom_data(data) + super(data).merge(markdown: true) + end +end diff --git a/app/models/project_snippet.rb b/app/models/project_snippet.rb index e732c1bd86f..ffb08e10f1f 100644 --- a/app/models/project_snippet.rb +++ b/app/models/project_snippet.rb @@ -4,4 +4,5 @@ class ProjectSnippet < Snippet belongs_to :project validates :project, presence: true + validates :secret, inclusion: { in: [false] } end diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index f02ccd9e55e..48c96203921 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -58,7 +58,7 @@ class ProjectWiki end def wiki_base_path - [Gitlab.config.gitlab.relative_url_root, '/', @project.full_path, '/wikis'].join('') + [Gitlab.config.gitlab.relative_url_root, '/', @project.full_path, '/-', '/wikis'].join('') end # Returns the Gitlab::Git::Wiki object. diff --git a/app/models/readme_blob.rb b/app/models/readme_blob.rb index 7b49fa632f6..695b4e3ffe3 100644 --- a/app/models/readme_blob.rb +++ b/app/models/readme_blob.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ReadmeBlob < SimpleDelegator + include BlobActiveModel + attr_reader :repository def initialize(blob, repository) diff --git a/app/models/release.rb b/app/models/release.rb index 401e8359f47..4fac64689ab 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -73,6 +73,14 @@ class Release < ApplicationRecord self.read_attribute(:name) || tag end + def evidence_sha + evidence&.summary_sha + end + + def evidence_summary + evidence&.summary || {} + end + private def actual_sha diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb index c165a1a9b0d..1e5c93cd913 100644 --- a/app/models/remote_mirror.rb +++ b/app/models/remote_mirror.rb @@ -3,6 +3,7 @@ class RemoteMirror < ApplicationRecord include AfterCommitQueue include MirrorAuthentication + include SafeUrl MAX_FIRST_RUNTIME = 3.hours MAX_INCREMENTAL_RUNTIME = 1.hour @@ -194,13 +195,7 @@ class RemoteMirror < ApplicationRecord end def safe_url - return if url.nil? - - result = URI.parse(url) - result.password = '*****' if result.password - result.user = '*****' if result.user && result.user != 'git' # tokens or other data may be saved as user - result.to_s - rescue URI::Error + super(usernames_whitelist: %w[git]) end def ensure_remote! diff --git a/app/models/repository.rb b/app/models/repository.rb index b9f57169ea5..2a67c26d840 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -925,7 +925,22 @@ class Repository def ancestor?(ancestor_id, descendant_id) return false if ancestor_id.nil? || descendant_id.nil? - raw_repository.ancestor?(ancestor_id, descendant_id) + counter = Gitlab::Metrics.counter( + :repository_ancestor_calls_total, + 'The number of times we call Repository#ancestor with valid arguments') + cache_hit = true + + cache_key = "ancestor:#{ancestor_id}:#{descendant_id}" + result = request_store_cache.fetch(cache_key) do + cache.fetch(cache_key) do + cache_hit = false + raw_repository.ancestor?(ancestor_id, descendant_id) + end + end + + counter.increment(cache_hit: cache_hit.to_s) + + result end def fetch_as_mirror(url, forced: false, refmap: :all_refs, remote_name: nil, prune: true) @@ -1052,18 +1067,19 @@ class Repository return rebase_deprecated(user, merge_request) end - MergeRequest.transaction do - raw.rebase( - user, - merge_request.id, - branch: merge_request.source_branch, - branch_sha: merge_request.source_branch_sha, - remote_repository: merge_request.target_project.repository.raw, - remote_branch: merge_request.target_branch - ) do |commit_id| - merge_request.update!(rebase_commit_sha: commit_id, merge_error: nil) - end + raw.rebase( + user, + merge_request.id, + branch: merge_request.source_branch, + branch_sha: merge_request.source_branch_sha, + remote_repository: merge_request.target_project.repository.raw, + remote_branch: merge_request.target_branch + ) do |commit_id| + merge_request.update!(rebase_commit_sha: commit_id, merge_error: nil) end + rescue StandardError => error + merge_request.update!(rebase_commit_sha: nil) + raise error end def squash(user, merge_request, message) diff --git a/app/models/sentry_issue.rb b/app/models/sentry_issue.rb new file mode 100644 index 00000000000..6be52f99562 --- /dev/null +++ b/app/models/sentry_issue.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class SentryIssue < ApplicationRecord + belongs_to :issue + + validates :issue, uniqueness: true, presence: true + validates :sentry_issue_identifier, + uniqueness: true, + presence: true +end diff --git a/app/models/serverless/domain_cluster.rb b/app/models/serverless/domain_cluster.rb new file mode 100644 index 00000000000..a8365649dd1 --- /dev/null +++ b/app/models/serverless/domain_cluster.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Serverless + class DomainCluster < ApplicationRecord + self.table_name = 'serverless_domain_cluster' + + belongs_to :pages_domain + belongs_to :knative, class_name: 'Clusters::Applications::Knative', foreign_key: 'clusters_applications_knative_id' + belongs_to :creator, class_name: 'User', optional: true + + validates :pages_domain, :knative, :uuid, presence: true + validates :uuid, uniqueness: true, length: { is: 14 } + end +end diff --git a/app/models/service.rb b/app/models/service.rb index 6d5b974dd31..95b7c6927cf 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -155,6 +155,14 @@ class Service < ApplicationRecord end end + def configurable_event_actions + self.class.supported_event_actions + end + + def self.supported_event_actions + %w() + end + def supported_events self.class.supported_events end @@ -281,6 +289,7 @@ class Service < ApplicationRecord slack teamcity microsoft_teams + unify_circuit ] if Rails.env.development? diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 4010a3e2167..92746d28f05 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -37,6 +37,7 @@ class Snippet < ApplicationRecord belongs_to :project has_many :notes, as: :noteable, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :user_mentions, class_name: "SnippetUserMention" delegate :name, :email, to: :author, prefix: true, allow_nil: true @@ -46,23 +47,42 @@ class Snippet < ApplicationRecord length: { maximum: 255 } validates :content, presence: true + validates :content, + length: { + maximum: ->(_) { Gitlab::CurrentSettings.snippet_size_limit }, + message: -> (_, data) do + current_value = ActiveSupport::NumberHelper.number_to_human_size(data[:value].size) + max_size = ActiveSupport::NumberHelper.number_to_human_size(Gitlab::CurrentSettings.snippet_size_limit) + + _("is too long (%{current_value}). The maximum size is %{max_size}.") % { current_value: current_value, max_size: max_size } + end + }, + if: :content_changed? + validates :visibility_level, inclusion: { in: Gitlab::VisibilityLevel.values } # Scopes scope :are_internal, -> { where(visibility_level: Snippet::INTERNAL) } scope :are_private, -> { where(visibility_level: Snippet::PRIVATE) } - scope :are_public, -> { where(visibility_level: Snippet::PUBLIC) } - scope :public_and_internal, -> { where(visibility_level: [Snippet::PUBLIC, Snippet::INTERNAL]) } + scope :are_public, -> { public_only } + scope :are_secret, -> { public_only.where(secret: true) } scope :fresh, -> { order("created_at DESC") } scope :inc_author, -> { includes(:author) } scope :inc_relations_for_view, -> { includes(author: :status) } + attr_mentionable :description + participant :author participant :notes_with_associations attr_spammable :title, spam_title: true attr_spammable :content, spam_description: true + attr_encrypted :secret_token, + key: Settings.attr_encrypted_db_key_base_truncated, + mode: :per_attribute_iv, + algorithm: 'aes-256-cbc' + def self.with_optional_visibility(value = nil) if value where(visibility_level: value) @@ -112,11 +132,8 @@ class Snippet < ApplicationRecord end def self.visible_to_or_authored_by(user) - where( - 'snippets.visibility_level IN (?) OR snippets.author_id = ?', - Gitlab::VisibilityLevel.levels_for_user(user), - user.id - ) + query = where(visibility_level: Gitlab::VisibilityLevel.levels_for_user(user)) + query.or(where(author_id: user.id)) end def self.reference_prefix @@ -222,6 +239,19 @@ class Snippet < ApplicationRecord model_name.singular end + def valid_secret_token?(token) + return false unless token && secret_token + + ActiveSupport::SecurityUtils.secure_compare(token.to_s, secret_token.to_s) + end + + def as_json(options = {}) + options[:except] = Array.wrap(options[:except]) + options[:except] << :secret_token + + super + end + class << self # Searches for snippets with a matching title or file name. # diff --git a/app/models/snippet_user_mention.rb b/app/models/snippet_user_mention.rb new file mode 100644 index 00000000000..87ce77a5787 --- /dev/null +++ b/app/models/snippet_user_mention.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class SnippetUserMention < UserMention + belongs_to :snippet + belongs_to :note +end diff --git a/app/models/timelog.rb b/app/models/timelog.rb index 048134fbf04..4ddaf6bcb86 100644 --- a/app/models/timelog.rb +++ b/app/models/timelog.rb @@ -8,6 +8,18 @@ class Timelog < ApplicationRecord belongs_to :merge_request, touch: true belongs_to :user + scope :for_issues_in_group, -> (group) do + joins(:issue).where( + 'EXISTS (?)', + Project.select(1).where(namespace: group.self_and_descendants) + .where('issues.project_id = projects.id') + ) + end + + scope :between_dates, -> (start_date, end_date) do + where('spent_at BETWEEN ? AND ?', start_date, end_date) + end + def issuable issue || merge_request end diff --git a/app/models/upload.rb b/app/models/upload.rb index 8c409641452..46ae924bf8c 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -23,6 +23,21 @@ class Upload < ApplicationRecord after_destroy :delete_file!, if: -> { uploader_class <= FileUploader } class << self + def inner_join_local_uploads_projects + upload_table = Upload.arel_table + project_table = Project.arel_table + + join_statement = upload_table.project(upload_table[Arel.star]) + .join(project_table) + .on( + upload_table[:model_type].eq('Project') + .and(upload_table[:model_id].eq(project_table[:id])) + .and(upload_table[:store].eq(ObjectStorage::Store::LOCAL)) + ) + + joins(join_statement.join_sources) + end + ## # FastDestroyAll concerns def begin_fast_destroy @@ -88,10 +103,8 @@ class Upload < ApplicationRecord # Help sysadmins find missing upload files if persisted? && !exist - if Gitlab::Sentry.enabled? - Raven.capture_message(_("Upload file does not exist"), extra: self.attributes) - end - + exception = RuntimeError.new("Uploaded file does not exist") + Gitlab::ErrorTracking.track_exception(exception, self.attributes) Gitlab::Metrics.counter(:upload_file_does_not_exist_total, _('The number of times an upload record could not find its file')).increment end diff --git a/app/models/uploads/local.rb b/app/models/uploads/local.rb index 2901c33c359..bd295a66838 100644 --- a/app/models/uploads/local.rb +++ b/app/models/uploads/local.rb @@ -23,7 +23,8 @@ module Uploads unless in_uploads?(path) message = "Path '#{path}' is not in uploads dir, skipping" logger.warn(message) - Gitlab::Sentry.track_exception(RuntimeError.new(message), extra: { uploads_dir: storage_dir }) + Gitlab::ErrorTracking.track_and_raise_for_dev_exception( + RuntimeError.new(message), uploads_dir: storage_dir) return end diff --git a/app/models/user.rb b/app/models/user.rb index d0e758b0055..18bf5ceaa0e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -110,6 +110,10 @@ class User < ApplicationRecord through: :group_members, source: :group alias_attribute :masters_groups, :maintainers_groups + has_many :reporter_developer_maintainer_owned_groups, + -> { where(members: { access_level: [Gitlab::Access::REPORTER, Gitlab::Access::DEVELOPER, Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) }, + through: :group_members, + source: :group # Projects has_many :groups_projects, through: :groups, source: :projects @@ -310,6 +314,13 @@ class User < ApplicationRecord scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) } scope :with_public_profile, -> { where(private_profile: false) } + scope :with_expiring_and_not_notified_personal_access_tokens, ->(at) do + where('EXISTS (?)', + ::PersonalAccessToken + .where('personal_access_tokens.user_id = users.id') + .expiring_and_not_notified(at).select(1)) + end + def self.with_visible_profile(user) return with_public_profile if user.nil? @@ -370,6 +381,11 @@ class User < ApplicationRecord # Class methods # class << self + # Devise method overridden to allow support for dynamic password lengths + def password_length + Gitlab::CurrentSettings.minimum_password_length..Devise.password_length.max + end + # Devise method overridden to allow sign in with email or username def find_for_database_authentication(warden_conditions) conditions = warden_conditions.dup @@ -989,8 +1005,12 @@ class User < ApplicationRecord @ldap_identity ||= identities.find_by(["provider LIKE ?", "ldap%"]) end + def matches_identity?(provider, extern_uid) + identities.where(provider: provider, extern_uid: extern_uid).exists? + end + def project_deploy_keys - DeployKey.in_projects(authorized_projects.select(:id)).distinct(:id) + @project_deploy_keys ||= DeployKey.in_projects(authorized_projects.select(:id)).distinct(:id) end def highest_role @@ -1453,9 +1473,7 @@ class User < ApplicationRecord self.admin = (new_level == 'admin') end - # Does the user have access to all private groups & projects? - # Overridden in EE to also check auditor? - def full_private_access? + def can_read_all_resources? can?(:read_all_resources) end diff --git a/app/models/user_callout_enums.rb b/app/models/user_callout_enums.rb index e9f25d833d0..ef0b2407e23 100644 --- a/app/models/user_callout_enums.rb +++ b/app/models/user_callout_enums.rb @@ -14,7 +14,8 @@ module UserCalloutEnums gke_cluster_integration: 1, gcp_signup_offer: 2, cluster_security_warning: 3, - suggest_popover_dismissed: 9 + suggest_popover_dismissed: 9, + tabs_position_highlight: 10 } end end diff --git a/app/models/user_mention.rb b/app/models/user_mention.rb new file mode 100644 index 00000000000..a85c6168cea --- /dev/null +++ b/app/models/user_mention.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class UserMention < ApplicationRecord + self.abstract_class = true + + def has_mentions? + mentioned_users_ids.present? || mentioned_groups_ids.present? || mentioned_projects_ids.present? + end + + private + + def mentioned_users + User.where(id: mentioned_users_ids) + end + + def mentioned_groups + Group.where(id: mentioned_groups_ids) + end + + def mentioned_projects + Project.where(id: mentioned_projects_ids) + end +end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index f9c562364cb..c6867e48cbf 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -274,6 +274,10 @@ class WikiPage @attributes.merge!(attrs) end + def to_ability_name + 'wiki_page' + end + private # Process and format the title based on the user input. diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index 8f5c6957a20..3a16f7dc239 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -40,6 +40,7 @@ class BasePolicy < DeclarativePolicy::Base prevent :read_cross_project end + # Policy extended in EE to also enable auditors rule { admin }.enable :read_all_resources rule { default }.enable :read_cross_project diff --git a/app/policies/blob_policy.rb b/app/policies/blob_policy.rb new file mode 100644 index 00000000000..639b9dfeea7 --- /dev/null +++ b/app/policies/blob_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class BlobPolicy < BasePolicy + delegate { @subject.project } + + rule { can?(:download_code) }.enable :read_blob +end diff --git a/app/policies/deploy_key_policy.rb b/app/policies/deploy_key_policy.rb index 7f0ec011e79..b117bb57921 100644 --- a/app/policies/deploy_key_policy.rb +++ b/app/policies/deploy_key_policy.rb @@ -3,10 +3,7 @@ class DeployKeyPolicy < BasePolicy with_options scope: :subject, score: 0 condition(:private_deploy_key) { @subject.private? } - - # rubocop: disable CodeReuse/ActiveRecord - condition(:has_deploy_key) { @user.project_deploy_keys.exists?(id: @subject.id) } - # rubocop: enable CodeReuse/ActiveRecord + condition(:has_deploy_key) { @user.project_deploy_keys.any? { |pdk| pdk.id.eql?(@subject.id) } } rule { anonymous }.prevent_all diff --git a/app/policies/error_tracking/detailed_error_policy.rb b/app/policies/error_tracking/detailed_error_policy.rb new file mode 100644 index 00000000000..cb74242d46a --- /dev/null +++ b/app/policies/error_tracking/detailed_error_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ErrorTracking + class DetailedErrorPolicy < BasePolicy + delegate { @subject.gitlab_project } + end +end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index eca73f0a241..f212bb06bc9 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -75,12 +75,15 @@ class GlobalPolicy < BasePolicy rule { ~anonymous }.policy do enable :read_instance_metadata + enable :create_personal_snippet end rule { admin }.policy do enable :read_custom_attribute enable :update_custom_attribute end + + rule { external_user }.prevent :create_personal_snippet end GlobalPolicy.prepend_if_ee('EE::GlobalPolicy') diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb index 91a8f3a7133..c2fcf1a1010 100644 --- a/app/policies/personal_snippet_policy.rb +++ b/app/policies/personal_snippet_policy.rb @@ -13,14 +13,10 @@ class PersonalSnippetPolicy < BasePolicy rule { is_author | admin }.policy do enable :read_personal_snippet enable :update_personal_snippet - enable :destroy_personal_snippet enable :admin_personal_snippet enable :create_note end - rule { ~anonymous }.enable :create_personal_snippet - rule { external_user }.prevent :create_personal_snippet - rule { internal_snippet & ~external_user }.policy do enable :read_personal_snippet enable :create_note @@ -31,4 +27,7 @@ class PersonalSnippetPolicy < BasePolicy rule { can?(:create_note) }.enable :award_emoji rule { can?(:read_all_resources) }.enable :read_personal_snippet + + # Aliasing the ability to ease GraphQL permissions check + rule { can?(:read_personal_snippet) }.enable :read_snippet end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index ff70c6e6aeb..7b0297ea81b 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -262,6 +262,7 @@ class ProjectPolicy < BasePolicy enable :update_container_image enable :destroy_container_image enable :create_environment + enable :update_environment enable :create_deployment enable :update_deployment enable :create_release @@ -278,8 +279,6 @@ class ProjectPolicy < BasePolicy enable :admin_board enable :push_to_delete_protected_branch enable :update_project_snippet - enable :update_environment - enable :update_deployment enable :admin_project_snippet enable :admin_project_member enable :admin_note diff --git a/app/policies/project_snippet_policy.rb b/app/policies/project_snippet_policy.rb index d9d09eb04cd..a9094fbd958 100644 --- a/app/policies/project_snippet_policy.rb +++ b/app/policies/project_snippet_policy.rb @@ -38,6 +38,10 @@ class ProjectSnippetPolicy < BasePolicy rule { public_snippet }.enable :read_project_snippet + rule { is_author & ~project.reporter & ~admin }.policy do + prevent :admin_project_snippet + end + rule { is_author | admin }.policy do enable :read_project_snippet enable :update_project_snippet @@ -45,6 +49,9 @@ class ProjectSnippetPolicy < BasePolicy end rule { ~can?(:read_project_snippet) }.prevent :create_note + + # Aliasing the ability to ease GraphQL permissions check + rule { can?(:read_project_snippet) }.enable :read_snippet end ProjectSnippetPolicy.prepend_if_ee('EE::ProjectSnippetPolicy') diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index e1efd84e510..d092a2de882 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -10,6 +10,9 @@ class UserPolicy < BasePolicy desc "The profile is private" condition(:private_profile, scope: :subject, score: 0) { @subject.private_profile? } + desc "The user is blocked" + condition(:blocked_user, scope: :subject, score: 0) { @subject.blocked? } + rule { ~restricted_public_level }.enable :read_user rule { ~anonymous }.enable :read_user @@ -20,5 +23,5 @@ class UserPolicy < BasePolicy end rule { default }.enable :read_user_profile - rule { private_profile & ~(user_is_self | admin) }.prevent :read_user_profile + rule { (private_profile | blocked_user) & ~(user_is_self | admin) }.prevent :read_user_profile end diff --git a/app/policies/wiki_page_policy.rb b/app/policies/wiki_page_policy.rb new file mode 100644 index 00000000000..468632c9085 --- /dev/null +++ b/app/policies/wiki_page_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class WikiPagePolicy < BasePolicy + delegate { @subject.wiki.project } + + rule { can?(:read_wiki) }.enable :read_wiki_page +end diff --git a/app/presenters/ci/legacy_stage_presenter.rb b/app/presenters/ci/legacy_stage_presenter.rb new file mode 100644 index 00000000000..56e268cff9f --- /dev/null +++ b/app/presenters/ci/legacy_stage_presenter.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Ci + class LegacyStagePresenter < Gitlab::View::Presenter::Delegated + presents :legacy_stage + + def latest_ordered_statuses + preload_statuses(legacy_stage.statuses.latest_ordered) + end + + def retried_ordered_statuses + preload_statuses(legacy_stage.statuses.retried_ordered) + end + + private + + def preload_statuses(statuses) + loaded_statuses = statuses.load + statuses.tap do |statuses| + # rubocop: disable CodeReuse/ActiveRecord + ActiveRecord::Associations::Preloader.new.preload(preloadable_statuses(loaded_statuses), %w[tags job_artifacts_archive metadata]) + # rubocop: enable CodeReuse/ActiveRecord + end + end + + def preloadable_statuses(statuses) + statuses.reject do |status| + status.instance_of?(::GenericCommitStatus) || status.instance_of?(::Ci::Bridge) + end + end + end +end diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index d81b1e6c522..f01ff56540a 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -8,7 +8,9 @@ module Ci # We use a class method here instead of a constant, allowing EE to redefine # the returned `Hash` more easily. def self.failure_reasons - { config_error: 'CI/CD YAML configuration error!' } + { unknown_failure: 'Unknown pipeline failure!', + config_error: 'CI/CD YAML configuration error!', + external_validation_failure: 'External pipeline validation failed!' } end presents :pipeline diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb index 2306f55f1f4..6b1d82e7557 100644 --- a/app/presenters/clusterable_presenter.rb +++ b/app/presenters/clusterable_presenter.rb @@ -29,18 +29,10 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated new_polymorphic_path([clusterable, :cluster], options) end - def aws_api_proxy_path(resource) - polymorphic_path([clusterable, :clusters], action: :aws_proxy, resource: resource) - end - def authorize_aws_role_path polymorphic_path([clusterable, :clusters], action: :authorize_aws_role) end - def revoke_aws_role_path - polymorphic_path([clusterable, :clusters], action: :revoke_aws_role) - end - def create_user_clusters_path polymorphic_path([clusterable, :clusters], action: :create_user) end @@ -65,6 +57,10 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated raise NotImplementedError end + def clear_cluster_cache_path(cluster) + raise NotImplementedError + end + def cluster_path(cluster, params = {}) raise NotImplementedError end diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb index 1634d2479a0..97771d84031 100644 --- a/app/presenters/clusters/cluster_presenter.rb +++ b/app/presenters/clusters/cluster_presenter.rb @@ -18,8 +18,20 @@ module Clusters end end - def gke_cluster_url - "https://console.cloud.google.com/kubernetes/clusters/details/#{provider.zone}/#{name}" if gcp? + def provider_label + if aws? + s_('ClusterIntegration|Elastic Kubernetes Service') + elsif gcp? + s_('ClusterIntegration|Google Kubernetes Engine') + end + end + + def provider_management_url + if aws? + "https://console.aws.amazon.com/eks/home?region=#{provider.region}\#/clusters/#{name}" + elsif gcp? + "https://console.cloud.google.com/kubernetes/clusters/details/#{provider.zone}/#{name}" + end end def can_read_cluster? diff --git a/app/presenters/conversational_development_index/metric_presenter.rb b/app/presenters/dev_ops_score/metric_presenter.rb index 9639b84cf56..d22beefee54 100644 --- a/app/presenters/conversational_development_index/metric_presenter.rb +++ b/app/presenters/dev_ops_score/metric_presenter.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ConversationalDevelopmentIndex +module DevOpsScore class MetricPresenter < Gitlab::View::Presenter::Simple def cards [ diff --git a/app/presenters/group_clusterable_presenter.rb b/app/presenters/group_clusterable_presenter.rb index 54cea19b18e..21db2f6f96b 100644 --- a/app/presenters/group_clusterable_presenter.rb +++ b/app/presenters/group_clusterable_presenter.rb @@ -19,6 +19,11 @@ class GroupClusterablePresenter < ClusterablePresenter update_applications_group_cluster_path(clusterable, cluster, application) end + override :clear_cluster_cache_path + def clear_cluster_cache_path(cluster) + clear_cache_group_cluster_path(clusterable, cluster) + end + override :cluster_path def cluster_path(cluster, params = {}) group_cluster_path(clusterable, cluster, params) diff --git a/app/presenters/hooks/project_hook_presenter.rb b/app/presenters/hooks/project_hook_presenter.rb new file mode 100644 index 00000000000..a65c7221b5a --- /dev/null +++ b/app/presenters/hooks/project_hook_presenter.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ProjectHookPresenter < Gitlab::View::Presenter::Delegated + presents :project_hook + + def logs_details_path(log) + project_hook_hook_log_path(project, self, log) + end + + def logs_retry_path(log) + retry_project_hook_hook_log_path(project, self, log) + end +end diff --git a/app/presenters/hooks/service_hook_presenter.rb b/app/presenters/hooks/service_hook_presenter.rb new file mode 100644 index 00000000000..bc20d5b1a3b --- /dev/null +++ b/app/presenters/hooks/service_hook_presenter.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ServiceHookPresenter < Gitlab::View::Presenter::Delegated + presents :service_hook + + def logs_details_path(log) + project_service_hook_log_path(service.project, service, log) + end + + def logs_retry_path(log) + retry_project_service_hook_log_path(service.project, service, log) + end +end diff --git a/app/presenters/instance_clusterable_presenter.rb b/app/presenters/instance_clusterable_presenter.rb index c6572e8ce71..0c267fd5735 100644 --- a/app/presenters/instance_clusterable_presenter.rb +++ b/app/presenters/instance_clusterable_presenter.rb @@ -37,6 +37,11 @@ class InstanceClusterablePresenter < ClusterablePresenter update_applications_admin_cluster_path(cluster, application) end + override :clear_cluster_cache_path + def clear_cluster_cache_path(cluster) + clear_cache_admin_cluster_path(cluster) + end + override :cluster_path def cluster_path(cluster, params = {}) admin_cluster_path(cluster, params) @@ -62,16 +67,6 @@ class InstanceClusterablePresenter < ClusterablePresenter authorize_aws_role_admin_clusters_path end - override :revoke_aws_role_path - def revoke_aws_role_path - revoke_aws_role_admin_clusters_path - end - - override :aws_api_proxy_path - def aws_api_proxy_path(resource) - aws_proxy_admin_clusters_path(resource: resource) - end - override :empty_state_help_text def empty_state_help_text s_('ClusterIntegration|Adding an integration will share the cluster across all projects.') diff --git a/app/presenters/project_clusterable_presenter.rb b/app/presenters/project_clusterable_presenter.rb index 3fab69fff7a..5c56d42ed27 100644 --- a/app/presenters/project_clusterable_presenter.rb +++ b/app/presenters/project_clusterable_presenter.rb @@ -19,6 +19,11 @@ class ProjectClusterablePresenter < ClusterablePresenter update_applications_project_cluster_path(clusterable, cluster, application) end + override :clear_cluster_cache_path + def clear_cluster_cache_path(cluster) + clear_cache_project_cluster_path(clusterable, cluster) + end + override :cluster_path def cluster_path(cluster, params = {}) project_cluster_path(clusterable, cluster, params) diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb index 9bb7fe13593..66211d02696 100644 --- a/app/presenters/projects/settings/deploy_keys_presenter.rb +++ b/app/presenters/projects/settings/deploy_keys_presenter.rb @@ -3,6 +3,8 @@ module Projects module Settings class DeployKeysPresenter < Gitlab::View::Presenter::Simple + include Gitlab::Utils::StrongMemoize + presents :project delegate :size, to: :enabled_keys, prefix: true delegate :size, to: :available_project_keys, prefix: true @@ -13,37 +15,45 @@ module Projects end def enabled_keys - project.deploy_keys + strong_memoize(:enabled_keys) do + project.deploy_keys.with_projects + end end def available_keys - current_user - .accessible_deploy_keys - .id_not_in(enabled_keys.select(:id)) - .with_projects + strong_memoize(:available_keys) do + current_user + .accessible_deploy_keys + .id_not_in(enabled_keys.select(:id)) + .with_projects + end end def available_project_keys - current_user - .project_deploy_keys - .id_not_in(enabled_keys.select(:id)) - .with_projects + strong_memoize(:available_project_keys) do + current_user + .project_deploy_keys + .id_not_in(enabled_keys.select(:id)) + .with_projects + end end def available_public_keys - DeployKey - .are_public - .id_not_in(enabled_keys.select(:id)) - .id_not_in(available_project_keys.select(:id)) - .with_projects + strong_memoize(:available_public_keys) do + DeployKey + .are_public + .id_not_in(enabled_keys.select(:id)) + .id_not_in(available_project_keys.select(:id)) + .with_projects + end end def as_json serializer = DeployKeySerializer.new # rubocop: disable CodeReuse/Serializer - opts = { user: current_user, project: project } + opts = { user: current_user, project: project, readable_project_ids: readable_project_ids } { - enabled_keys: serializer.represent(enabled_keys.with_projects, opts), + enabled_keys: serializer.represent(enabled_keys, opts), available_project_keys: serializer.represent(available_project_keys, opts), public_keys: serializer.represent(available_public_keys, opts) } @@ -56,6 +66,26 @@ module Projects def form_partial_path 'projects/deploy_keys/form' end + + private + + # Caching all readable project ids for the user that are associated with the queried deploy keys + def readable_project_ids + strong_memoize(:readable_projects_by_id) do + Set.new(user_readable_project_ids) + end + end + + # rubocop: disable CodeReuse/ActiveRecord + def user_readable_project_ids + project_ids = (available_keys + available_project_keys + available_public_keys) + .flat_map { |deploy_key| deploy_key.deploy_keys_projects.map(&:project_id) } + .compact + .uniq + + current_user.authorized_projects(Gitlab::Access::GUEST).id_in(project_ids).pluck(:id) + end + # rubocop: enable CodeReuse/ActiveRecord end end end diff --git a/app/presenters/release_presenter.rb b/app/presenters/release_presenter.rb index 42463d6dbda..b38bbc8d96c 100644 --- a/app/presenters/release_presenter.rb +++ b/app/presenters/release_presenter.rb @@ -37,6 +37,12 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated edit_project_release_url(project, release) end + def evidence_file_path + return unless release.evidence.present? + + evidence_project_release_url(project, tag, format: :json) + end + private def can_download_code? @@ -52,6 +58,6 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated end def release_edit_page_available? - ::Feature.enabled?(:release_edit_page, project, default_enabled: true) + can?(current_user, :update_release, release) end end diff --git a/app/presenters/sentry_detailed_error_presenter.rb b/app/presenters/sentry_detailed_error_presenter.rb new file mode 100644 index 00000000000..9329f987879 --- /dev/null +++ b/app/presenters/sentry_detailed_error_presenter.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class SentryDetailedErrorPresenter < Gitlab::View::Presenter::Delegated + presents :error + + FrequencyStruct = Struct.new(:time, :count, keyword_init: true) + + def frequency + utc_offset = Time.zone_offset('UTC') + + error.frequency.map do |f| + FrequencyStruct.new(time: Time.at(f[0], in: utc_offset), count: f[1]) + end + end +end diff --git a/app/presenters/snippet_presenter.rb b/app/presenters/snippet_presenter.rb new file mode 100644 index 00000000000..a453be18b95 --- /dev/null +++ b/app/presenters/snippet_presenter.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class SnippetPresenter < Gitlab::View::Presenter::Delegated + presents :snippet + + def web_url + Gitlab::UrlBuilder.build(snippet) + end + + def raw_url + Gitlab::UrlBuilder.build(snippet, raw: true) + end + + def can_read_snippet? + can_access_resource?("read") + end + + def can_update_snippet? + can_access_resource?("update") + end + + def can_admin_snippet? + can_access_resource?("admin") + end + + def can_report_as_spam? + snippet.submittable_as_spam_by?(current_user) + end + + private + + def can_access_resource?(ability_prefix) + can?(current_user, ability_name(ability_prefix), snippet) + end + + def ability_name(ability_prefix) + "#{ability_prefix}_#{snippet.to_ability_name}".to_sym + end +end diff --git a/app/presenters/web_hook_log_presenter.rb b/app/presenters/web_hook_log_presenter.rb new file mode 100644 index 00000000000..fca03ddb5d7 --- /dev/null +++ b/app/presenters/web_hook_log_presenter.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class WebHookLogPresenter < Gitlab::View::Presenter::Delegated + presents :web_hook_log + + def details_path + web_hook.present.logs_details_path(self) + end + + def retry_path + web_hook.present.logs_retry_path(self) + end +end diff --git a/app/serializers/analytics_merge_request_entity.rb b/app/serializers/analytics_merge_request_entity.rb index 21d7eeb81b0..4344de2b9b4 100644 --- a/app/serializers/analytics_merge_request_entity.rb +++ b/app/serializers/analytics_merge_request_entity.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true class AnalyticsMergeRequestEntity < AnalyticsIssueEntity - expose :state + expose :state do |object| + MergeRequest.available_states.key(object[:state_id]) + end expose :url do |object| url_to(:namespace_project_merge_request, object) diff --git a/app/serializers/deploy_key_entity.rb b/app/serializers/deploy_key_entity.rb index 9a558d12bec..2682a47fbaa 100644 --- a/app/serializers/deploy_key_entity.rb +++ b/app/serializers/deploy_key_entity.rb @@ -11,8 +11,7 @@ class DeployKeyEntity < Grape::Entity expose :updated_at expose :deploy_keys_projects, using: DeployKeysProjectEntity do |deploy_key| deploy_key.deploy_keys_projects.select do |deploy_key_project| - !deploy_key_project.project&.pending_delete? && - Ability.allowed?(options[:user], :read_project, deploy_key_project.project) + !deploy_key_project.project&.pending_delete? && (allowed_to_read_project?(deploy_key_project.project) || options[:user].admin?) end end expose :can_edit @@ -23,4 +22,12 @@ class DeployKeyEntity < Grape::Entity Ability.allowed?(options[:user], :update_deploy_key, object) || Ability.allowed?(options[:user], :update_deploy_keys_project, object.deploy_keys_project_for(options[:project])) end + + def allowed_to_read_project?(project) + if options[:readable_project_ids] + options[:readable_project_ids].include?(project.id) + else + Ability.allowed?(options[:user], :read_project, project) + end + end end diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb index e6421315b34..94773eeebd0 100644 --- a/app/serializers/deployment_entity.rb +++ b/app/serializers/deployment_entity.rb @@ -37,6 +37,9 @@ class DeploymentEntity < Grape::Entity expose :commit, using: CommitEntity, if: -> (*) { include_details? } expose :manual_actions, using: JobEntity, if: -> (*) { include_details? && can_create_deployment? } expose :scheduled_actions, using: JobEntity, if: -> (*) { include_details? && can_create_deployment? } + expose :playable_build, expose_nil: false, if: -> (*) { include_details? && can_create_deployment? } do |deployment, options| + JobEntity.represent(deployment.playable_build, options.merge(only: [:play_path, :retry_path])) + end expose :cluster, using: ClusterBasicEntity @@ -47,7 +50,7 @@ class DeploymentEntity < Grape::Entity end def can_create_deployment? - can?(request.current_user, :create_deployment, request.project) + can?(request.current_user, :create_deployment, project) end def can_read_deployables? @@ -56,6 +59,10 @@ class DeploymentEntity < Grape::Entity # because it triggers a policy evaluation that involves multiple # Gitaly calls that might not be cached. # - can?(request.current_user, :read_build, request.project) + can?(request.current_user, :read_build, project) + end + + def project + request.try(:project) || options[:project] end end diff --git a/app/serializers/diff_file_metadata_entity.rb b/app/serializers/diff_file_metadata_entity.rb index 500a844b170..05280518f39 100644 --- a/app/serializers/diff_file_metadata_entity.rb +++ b/app/serializers/diff_file_metadata_entity.rb @@ -7,4 +7,7 @@ class DiffFileMetadataEntity < Grape::Entity expose :old_path expose :new_file?, as: :new_file expose :deleted_file?, as: :deleted_file + expose :file_hash do |diff_file| + Digest::SHA1.hexdigest(diff_file.file_path) + end end diff --git a/app/serializers/diffs_entity.rb b/app/serializers/diffs_entity.rb index 19875a1287c..88e09ae8c0b 100644 --- a/app/serializers/diffs_entity.rb +++ b/app/serializers/diffs_entity.rb @@ -42,13 +42,13 @@ class DiffsEntity < Grape::Entity # rubocop: disable CodeReuse/ActiveRecord expose :added_lines do |diffs| - diffs.diff_files.sum(&:added_lines) + diffs.raw_diff_files.sum(&:added_lines) end # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord expose :removed_lines do |diffs| - diffs.diff_files.sum(&:removed_lines) + diffs.raw_diff_files.sum(&:removed_lines) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/serializers/diffs_metadata_entity.rb b/app/serializers/diffs_metadata_entity.rb index c82c686e8ef..b7024721ea9 100644 --- a/app/serializers/diffs_metadata_entity.rb +++ b/app/serializers/diffs_metadata_entity.rb @@ -2,5 +2,5 @@ class DiffsMetadataEntity < DiffsEntity unexpose :diff_files - expose :diff_files, using: DiffFileMetadataEntity + expose :raw_diff_files, as: :diff_files, using: DiffFileMetadataEntity end diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb index bffd9de4978..74d6806e83f 100644 --- a/app/serializers/environment_entity.rb +++ b/app/serializers/environment_entity.rb @@ -24,6 +24,10 @@ class EnvironmentEntity < Grape::Entity stop_project_environment_path(environment.project, environment) end + expose :cancel_auto_stop_path, if: -> (*) { can_update_environment? } do |environment| + cancel_auto_stop_project_environment_path(environment.project, environment) + end + expose :cluster_type, if: ->(environment, _) { cluster_platform_kubernetes? } do |environment| cluster.cluster_type end @@ -37,6 +41,7 @@ class EnvironmentEntity < Grape::Entity end expose :created_at, :updated_at + expose :auto_stop_at, expose_nil: false expose :can_stop do |environment| environment.available? && can?(current_user, :stop_environment, environment) @@ -54,6 +59,10 @@ class EnvironmentEntity < Grape::Entity can?(request.current_user, :create_environment_terminal, environment) end + def can_update_environment? + can?(current_user, :update_environment, environment) + end + def cluster_platform_kubernetes? deployment_platform && deployment_platform.is_a?(Clusters::Platforms::Kubernetes) end diff --git a/app/serializers/environment_status_entity.rb b/app/serializers/environment_status_entity.rb index 811cc2ad5af..40db23c143e 100644 --- a/app/serializers/environment_status_entity.rb +++ b/app/serializers/environment_status_entity.rb @@ -37,6 +37,10 @@ class EnvironmentStatusEntity < Grape::Entity es.deployment.try(:formatted_deployment_time) end + expose :deployment, as: :details do |es, options| + DeploymentEntity.represent(es.deployment, options.merge(project: es.project, only: [:playable_build])) + end + expose :changes private diff --git a/app/serializers/error_tracking/detailed_error_entity.rb b/app/serializers/error_tracking/detailed_error_entity.rb index 8f08f84aa41..dd0cac8e4cd 100644 --- a/app/serializers/error_tracking/detailed_error_entity.rb +++ b/app/serializers/error_tracking/detailed_error_entity.rb @@ -10,6 +10,7 @@ module ErrorTracking :first_release_short_version, :first_seen, :frequency, + :gitlab_issue, :id, :last_release_last_commit, :last_release_short_version, diff --git a/app/serializers/group_child_entity.rb b/app/serializers/group_child_entity.rb index 20d7032c970..a7fe4d3f9b9 100644 --- a/app/serializers/group_child_entity.rb +++ b/app/serializers/group_child_entity.rb @@ -99,3 +99,5 @@ class GroupChildEntity < Grape::Entity end end end + +GroupChildEntity.prepend_if_ee('EE::GroupChildEntity') diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index a3d0298a495..98c0c703584 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -64,4 +64,12 @@ class IssueEntity < IssuableEntity expose :locked_discussion_docs_path, if: -> (issue) { issue.discussion_locked? } do |issue| help_page_path('user/discussions/index.md', anchor: 'lock-discussions') end + + expose :is_project_archived do |issue| + issue.project.archived? + end + + expose :archived_project_docs_path, if: -> (issue) { issue.project.archived? } do |issue| + help_page_path('user/project/settings/index.md', anchor: 'archiving-a-project') + end end diff --git a/app/serializers/merge_request_noteable_entity.rb b/app/serializers/merge_request_noteable_entity.rb index 9504fdd8eac..8e7456ce059 100644 --- a/app/serializers/merge_request_noteable_entity.rb +++ b/app/serializers/merge_request_noteable_entity.rb @@ -42,6 +42,18 @@ class MergeRequestNoteableEntity < IssuableEntity end end + expose :locked_discussion_docs_path, if: -> (merge_request) { merge_request.discussion_locked? } do |merge_request| + help_page_path('user/discussions/index.md', anchor: 'lock-discussions') + end + + expose :is_project_archived do |merge_request| + merge_request.project.archived? + end + + expose :archived_project_docs_path, if: -> (merge_request) { merge_request.project.archived? } do |merge_request| + help_page_path('user/project/settings/index.md', anchor: 'archiving-a-project') + end + private delegate :current_user, to: :request diff --git a/app/serializers/merge_request_poll_cached_widget_entity.rb b/app/serializers/merge_request_poll_cached_widget_entity.rb index a3186ecbcdf..2f8eb6650e8 100644 --- a/app/serializers/merge_request_poll_cached_widget_entity.rb +++ b/app/serializers/merge_request_poll_cached_widget_entity.rb @@ -15,7 +15,7 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity expose :target_project_id expose :squash expose :rebase_in_progress?, as: :rebase_in_progress - expose :default_squash_commit_message + expose :default_squash_commit_message, if: -> (merge_request, _) { merge_request.mergeable? } expose :commits_count expose :merge_ongoing?, as: :merge_ongoing expose :work_in_progress?, as: :work_in_progress @@ -25,8 +25,9 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity expose :source_branch_exists?, as: :source_branch_exists expose :branch_missing?, as: :branch_missing - expose :commits_without_merge_commits, using: MergeRequestWidgetCommitEntity do |merge_request| - merge_request.commits.without_merge_commits + expose :commits_without_merge_commits, using: MergeRequestWidgetCommitEntity, + if: -> (merge_request, _) { merge_request.mergeable? } do |merge_request| + merge_request.recent_commits.without_merge_commits end expose :diff_head_sha do |merge_request| merge_request.diff_head_sha.presence @@ -69,6 +70,10 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity presenter(merge_request).source_branch_with_namespace_link end + expose :diffs_path do |merge_request| + diffs_project_merge_request_path(merge_request.project, merge_request) + end + private delegate :current_user, to: :request @@ -101,3 +106,5 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity merged_by: merge_event&.author) end end + +MergeRequestPollCachedWidgetEntity.prepend_if_ee('EE::MergeRequestPollCachedWidgetEntity') diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb index 2a61187a856..a45026ea016 100644 --- a/app/serializers/merge_request_poll_widget_entity.rb +++ b/app/serializers/merge_request_poll_widget_entity.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true -class MergeRequestPollWidgetEntity < IssuableEntity +class MergeRequestPollWidgetEntity < Grape::Entity + include RequestAwareEntity + expose :auto_merge_strategy expose :available_auto_merge_strategies do |merge_request| AutoMergeService.new(merge_request.project, current_user).available_strategies(merge_request) # rubocop: disable CodeReuse/ServiceClass @@ -55,6 +57,10 @@ class MergeRequestPollWidgetEntity < IssuableEntity presenter(merge_request).ci_status end + expose :pipeline_coverage_delta do |merge_request| + presenter(merge_request).pipeline_coverage_delta + end + expose :cancel_auto_merge_path do |merge_request| presenter(merge_request).cancel_auto_merge_path end diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index eda7a36c2ee..2a81931c49f 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -3,6 +3,9 @@ class MergeRequestWidgetEntity < Grape::Entity include RequestAwareEntity + expose :id + expose :iid + expose :source_project_full_path do |merge_request| merge_request.source_project&.full_path end @@ -65,6 +68,8 @@ class MergeRequestWidgetEntity < Grape::Entity end def as_json(options = {}) + return super(options) if Feature.enabled?(:async_mr_widget) + super(options) .merge(MergeRequestPollCachedWidgetEntity.new(object, **@options.opts_hash).as_json(options)) .merge(MergeRequestPollWidgetEntity.new(object, **@options.opts_hash).as_json(options)) diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb index 94e8b174f0f..cddb894fd64 100644 --- a/app/serializers/pipeline_entity.rb +++ b/app/serializers/pipeline_entity.rb @@ -77,6 +77,10 @@ class PipelineEntity < Grape::Entity cancel_project_pipeline_path(pipeline.project, pipeline) end + expose :failed_builds, if: -> (*) { can_retry? }, using: JobEntity do |pipeline| + pipeline.builds.failed + end + private alias_method :pipeline, :object diff --git a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb index 6a33ec071db..7c0e9228b28 100644 --- a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb +++ b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb @@ -11,7 +11,7 @@ module AutoMerge end def process(merge_request) - return unless merge_request.actual_head_pipeline&.success? + return unless merge_request.actual_head_pipeline_success? return unless merge_request.mergeable? merge_request.merge_async(merge_request.merge_user_id, merge_request.merge_params) diff --git a/app/services/boards/lists/list_service.rb b/app/services/boards/lists/list_service.rb index 82cba1b68c4..c96ea970943 100644 --- a/app/services/boards/lists/list_service.rb +++ b/app/services/boards/lists/list_service.rb @@ -6,7 +6,7 @@ module Boards def execute(board) board.lists.create(list_type: :backlog) unless board.lists.backlog.exists? - board.lists.preload_associations + board.lists.preload_associated_models end end end diff --git a/app/services/branches/create_service.rb b/app/services/branches/create_service.rb new file mode 100644 index 00000000000..c8afd97e6bf --- /dev/null +++ b/app/services/branches/create_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Branches + class CreateService < BaseService + def execute(branch_name, ref, create_master_if_empty: true) + create_master_branch if create_master_if_empty && project.empty_repo? + + result = ::Branches::ValidateNewService.new(project).execute(branch_name) + + return result if result[:status] == :error + + new_branch = repository.add_branch(current_user, branch_name, ref) + + if new_branch + success(new_branch) + else + error("Invalid reference name: #{branch_name}") + end + rescue Gitlab::Git::PreReceiveError => ex + error(ex.message) + end + + def success(branch) + super().merge(branch: branch) + end + + private + + def create_master_branch + project.repository.create_file( + current_user, + '/README.md', + '', + message: 'Add README.md', + branch_name: 'master' + ) + end + end +end diff --git a/app/services/branches/delete_merged_service.rb b/app/services/branches/delete_merged_service.rb new file mode 100644 index 00000000000..9fd5964bf94 --- /dev/null +++ b/app/services/branches/delete_merged_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Branches + class DeleteMergedService < BaseService + def async_execute + DeleteMergedBranchesWorker.perform_async(project.id, current_user.id) + end + + def execute + raise Gitlab::Access::AccessDeniedError unless can?(current_user, :push_code, project) + + branches = project.repository.merged_branch_names + # Prevent deletion of branches relevant to open merge requests + branches -= merge_request_branch_names + # Prevent deletion of protected branches + branches = branches.reject { |branch| ProtectedBranch.protected?(project, branch) } + + branches.each do |branch| + ::Branches::DeleteService.new(project, current_user).execute(branch) + end + end + + private + + # rubocop: disable CodeReuse/ActiveRecord + def merge_request_branch_names + # reorder(nil) is necessary for SELECT DISTINCT because default scope adds an ORDER BY + source_names = project.origin_merge_requests.opened.reorder(nil).distinct.pluck(:source_branch) + target_names = project.merge_requests.opened.reorder(nil).distinct.pluck(:target_branch) + (source_names + target_names).uniq + end + # rubocop: enable CodeReuse/ActiveRecord + end +end diff --git a/app/services/branches/delete_service.rb b/app/services/branches/delete_service.rb new file mode 100644 index 00000000000..ca2b4556b58 --- /dev/null +++ b/app/services/branches/delete_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Branches + class DeleteService < BaseService + def execute(branch_name) + repository = project.repository + branch = repository.find_branch(branch_name) + + unless current_user.can?(:push_code, project) + return ServiceResponse.error( + message: 'You dont have push access to repo', + http_status: 405) + end + + unless branch + return ServiceResponse.error( + message: 'No such branch', + http_status: 404) + end + + if repository.rm_branch(current_user, branch_name) + ServiceResponse.success(message: 'Branch was deleted') + else + ServiceResponse.error( + message: 'Failed to remove branch', + http_status: 400) + end + rescue Gitlab::Git::PreReceiveError => ex + ServiceResponse.error(message: ex.message, http_status: 400) + end + end +end diff --git a/app/services/branches/validate_new_service.rb b/app/services/branches/validate_new_service.rb new file mode 100644 index 00000000000..e45183d160f --- /dev/null +++ b/app/services/branches/validate_new_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Branches + class ValidateNewService < BaseService + def initialize(project) + @project = project + end + + def execute(branch_name, force: false) + return error('Branch name is invalid') unless valid_name?(branch_name) + + if branch_exist?(branch_name) && !force + return error('Branch already exists') + end + + success + rescue Gitlab::Git::PreReceiveError => ex + error(ex.message) + end + + private + + def valid_name?(branch_name) + Gitlab::GitRefValidator.validate(branch_name) + end + + def branch_exist?(branch_name) + project.repository.branch_exists?(branch_name) + end + end +end diff --git a/app/services/ci/archive_trace_service.rb b/app/services/ci/archive_trace_service.rb index 8fad9e9c869..f143736ddc1 100644 --- a/app/services/ci/archive_trace_service.rb +++ b/app/services/ci/archive_trace_service.rb @@ -46,10 +46,10 @@ module Ci message: "Failed to archive trace. message: #{error.message}.", job_id: job.id) - Gitlab::Sentry - .track_exception(error, + Gitlab::ErrorTracking + .track_and_raise_for_dev_exception(error, issue_url: 'https://gitlab.com/gitlab-org/gitlab-foss/issues/51502', - extra: { job_id: job.id }) + job_id: job.id ) end end end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 5778a48bce6..ce3a9eb0772 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -16,6 +16,7 @@ module Ci Gitlab::Ci::Pipeline::Chain::EvaluateWorkflowRules, Gitlab::Ci::Pipeline::Chain::Seed, Gitlab::Ci::Pipeline::Chain::Limit::Size, + Gitlab::Ci::Pipeline::Chain::Validate::External, Gitlab::Ci::Pipeline::Chain::Populate, Gitlab::Ci::Pipeline::Chain::Create, Gitlab::Ci::Pipeline::Chain::Limit::Activity, @@ -57,7 +58,9 @@ module Ci cancel_pending_pipelines if project.auto_cancel_pending_pipelines? pipeline_created_counter.increment(source: source) - pipeline.process! + Ci::ProcessPipelineService + .new(pipeline) + .execute end end diff --git a/app/services/ci/generate_exposed_artifacts_report_service.rb b/app/services/ci/generate_exposed_artifacts_report_service.rb index b9bf580bcbc..1dbcd192279 100644 --- a/app/services/ci/generate_exposed_artifacts_report_service.rb +++ b/app/services/ci/generate_exposed_artifacts_report_service.rb @@ -15,7 +15,7 @@ module Ci data: data } rescue => e - Gitlab::Sentry.track_acceptable_exception(e, extra: { project_id: project.id }) + Gitlab::ErrorTracking.track_exception(e, project_id: project.id) { status: :error, key: key(base_pipeline, head_pipeline), diff --git a/app/services/ci/prepare_build_service.rb b/app/services/ci/prepare_build_service.rb index 3722faeb020..5d024c45e5f 100644 --- a/app/services/ci/prepare_build_service.rb +++ b/app/services/ci/prepare_build_service.rb @@ -13,7 +13,7 @@ module Ci build.enqueue! rescue => e - Gitlab::Sentry.track_acceptable_exception(e, extra: { build_id: build.id }) + Gitlab::ErrorTracking.track_exception(e, build_id: build.id) build.drop(:unmet_prerequisites) end diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb index 039670f58c8..f33cbf7ab29 100644 --- a/app/services/ci/process_pipeline_service.rb +++ b/app/services/ci/process_pipeline_service.rb @@ -1,14 +1,16 @@ # frozen_string_literal: true module Ci - class ProcessPipelineService < BaseService + class ProcessPipelineService include Gitlab::Utils::StrongMemoize attr_reader :pipeline - def execute(pipeline, trigger_build_ids = nil) + def initialize(pipeline) @pipeline = pipeline + end + def execute(trigger_build_ids = nil) update_retried success = process_stages_without_needs @@ -72,7 +74,7 @@ module Ci def process_build(build, current_status) Gitlab::OptimisticLocking.retry_lock(build) do |subject| - Ci::ProcessBuildService.new(project, @user) + Ci::ProcessBuildService.new(project, build.user) .execute(subject, current_status) end end @@ -129,5 +131,9 @@ module Ci .update_all(retried: true) if latest_statuses.any? end # rubocop: enable CodeReuse/ActiveRecord + + def project + pipeline.project + end end end diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index 30e2a66e04a..57c0cdd0602 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -128,13 +128,13 @@ module Ci end def track_exception_for_build(ex, build) - Gitlab::Sentry.track_acceptable_exception(ex, extra: { + Gitlab::ErrorTracking.track_exception(ex, build_id: build.id, build_name: build.name, build_stage: build.stage, pipeline_id: build.pipeline_id, project_id: build.project_id - }) + ) end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb index 42a13367a99..7d01de9ee68 100644 --- a/app/services/ci/retry_pipeline_service.rb +++ b/app/services/ci/retry_pipeline_service.rb @@ -9,13 +9,23 @@ module Ci raise Gitlab::Access::AccessDeniedError end - pipeline.retryable_builds.find_each do |build| + needs = Set.new + + pipeline.retryable_builds.preload_needs.find_each do |build| next unless can?(current_user, :update_build, build) Ci::RetryBuildService.new(project, current_user) .reprocess!(build) + + needs += build.needs.map(&:name) end + # In a DAG, the dependencies may have already completed. Figure out + # which builds have succeeded and use them to update the pipeline. If we don't + # do this, then builds will be stuck in the created state since their dependencies + # will never run. + completed_build_ids = pipeline.find_successful_build_ids_by_names(needs) if needs.any? + pipeline.builds.latest.skipped.find_each do |skipped| retry_optimistic_lock(skipped) { |build| build.process } end @@ -24,7 +34,9 @@ module Ci .new(project, current_user) .close_all(pipeline) - pipeline.process! + Ci::ProcessPipelineService + .new(pipeline) + .execute(completed_build_ids) end end end diff --git a/app/services/clusters/applications/base_helm_service.rb b/app/services/clusters/applications/base_helm_service.rb index 3e7f55f0c63..57bc8bc0d9b 100644 --- a/app/services/clusters/applications/base_helm_service.rb +++ b/app/services/clusters/applications/base_helm_service.rb @@ -21,14 +21,7 @@ module Clusters group_ids: app.cluster.group_ids } - logger_meta = meta.merge( - exception: error.class.name, - message: error.message, - backtrace: Gitlab::Profiler.clean_backtrace(error.backtrace) - ) - - logger.error(logger_meta) - Gitlab::Sentry.track_acceptable_exception(error, extra: meta) + Gitlab::ErrorTracking.track_exception(error, meta) end def log_event(event) @@ -68,8 +61,8 @@ module Clusters @update_command ||= app.update_command end - def upgrade_command(new_values = "") - app.upgrade_command(new_values) + def patch_command(new_values = "") + app.patch_command(new_values) end end end diff --git a/app/services/clusters/applications/ingress_modsecurity_usage_service.rb b/app/services/clusters/applications/ingress_modsecurity_usage_service.rb new file mode 100644 index 00000000000..4aac8bb3cbd --- /dev/null +++ b/app/services/clusters/applications/ingress_modsecurity_usage_service.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +# rubocop: disable CodeReuse/ActiveRecord +module Clusters + module Applications + ## + # This service measures usage of the Modsecurity Web Application Firewall across the entire + # instance's deployed environments. + # + # The default configuration is`AUTO_DEVOPS_MODSECURITY_SEC_RULE_ENGINE=DetectionOnly` so we + # measure non-default values via definition of either ci_variables or ci_pipeline_variables. + # Since both these values are encrypted, we must decrypt and count them in memory. + # + # NOTE: this service is an approximation as it does not yet take into account `environment_scope` or `ci_group_variables`. + ## + class IngressModsecurityUsageService + ADO_MODSEC_KEY = "AUTO_DEVOPS_MODSECURITY_SEC_RULE_ENGINE" + + def initialize(blocking_count: 0, disabled_count: 0) + @blocking_count = blocking_count + @disabled_count = disabled_count + end + + def execute + conditions = -> { merge(::Environment.available).merge(::Deployment.success).where(key: ADO_MODSEC_KEY) } + + ci_pipeline_var_enabled = + ::Ci::PipelineVariable + .joins(pipeline: { environments: :last_visible_deployment }) + .merge(conditions) + .order('deployments.environment_id, deployments.id DESC') + + ci_var_enabled = + ::Ci::Variable + .joins(project: { environments: :last_visible_deployment }) + .merge(conditions) + .merge( + # Give priority to pipeline variables by excluding from dataset + ::Ci::Variable.joins(project: :environments).where.not( + environments: { id: ci_pipeline_var_enabled.select('DISTINCT ON (deployments.environment_id) deployments.environment_id') } + ) + ).select('DISTINCT ON (deployments.environment_id) ci_variables.*') + + sum_modsec_config_counts( + ci_pipeline_var_enabled.select('DISTINCT ON (deployments.environment_id) ci_pipeline_variables.*') + ) + sum_modsec_config_counts(ci_var_enabled) + + { + ingress_modsecurity_blocking: @blocking_count, + ingress_modsecurity_disabled: @disabled_count + } + end + + private + + # These are encrypted so we must decrypt and count in memory + def sum_modsec_config_counts(dataset) + dataset.each do |var| + case var.value + when "On" then @blocking_count += 1 + when "Off" then @disabled_count += 1 + # `else` could be default or any unsupported user input + end + end + end + end + end +end diff --git a/app/services/clusters/aws/authorize_role_service.rb b/app/services/clusters/aws/authorize_role_service.rb new file mode 100644 index 00000000000..6eafce0597e --- /dev/null +++ b/app/services/clusters/aws/authorize_role_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Clusters + module Aws + class AuthorizeRoleService + attr_reader :user + + Response = Struct.new(:status, :body) + + ERRORS = [ + ActiveRecord::RecordInvalid, + Clusters::Aws::FetchCredentialsService::MissingRoleError, + ::Aws::Errors::MissingCredentialsError, + ::Aws::STS::Errors::ServiceError + ].freeze + + def initialize(user, params:) + @user = user + @params = params + end + + def execute + @role = create_or_update_role! + + Response.new(:ok, credentials) + rescue *ERRORS + Response.new(:unprocessable_entity, {}) + end + + private + + attr_reader :role, :params + + def create_or_update_role! + if role = user.aws_role + role.update!(params) + + role + else + user.create_aws_role!(params) + end + end + + def credentials + Clusters::Aws::FetchCredentialsService.new(role).execute + end + end + end +end diff --git a/app/services/clusters/aws/fetch_credentials_service.rb b/app/services/clusters/aws/fetch_credentials_service.rb index 2724d4b657b..33efc4cc120 100644 --- a/app/services/clusters/aws/fetch_credentials_service.rb +++ b/app/services/clusters/aws/fetch_credentials_service.rb @@ -7,9 +7,8 @@ module Clusters MissingRoleError = Class.new(StandardError) - def initialize(provision_role, region:, provider: nil) + def initialize(provision_role, provider: nil) @provision_role = provision_role - @region = region @provider = provider end @@ -20,13 +19,14 @@ module Clusters client: client, role_arn: provision_role.role_arn, role_session_name: session_name, - external_id: provision_role.role_external_id + external_id: provision_role.role_external_id, + policy: session_policy ).credentials end private - attr_reader :provider, :region + attr_reader :provider def client ::Aws::STS::Client.new(credentials: gitlab_credentials, region: region) @@ -44,6 +44,26 @@ module Clusters Gitlab::CurrentSettings.eks_secret_access_key end + def region + provider&.region || Clusters::Providers::Aws::DEFAULT_REGION + end + + ## + # If we haven't created a provider record yet, + # we restrict ourselves to read only access so + # that we can safely expose credentials to the + # frontend (to be used when populating the + # creation form). + def session_policy + if provider.nil? + File.read(read_only_policy) + end + end + + def read_only_policy + Rails.root.join('vendor', 'aws', 'iam', "eks_cluster_read_only_policy.json") + end + def session_name if provider.present? "gitlab-eks-cluster-#{provider.cluster_id}-user-#{provision_role.user_id}" diff --git a/app/services/clusters/aws/proxy_service.rb b/app/services/clusters/aws/proxy_service.rb deleted file mode 100644 index df8fc480005..00000000000 --- a/app/services/clusters/aws/proxy_service.rb +++ /dev/null @@ -1,134 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Aws - class ProxyService - DEFAULT_REGION = 'us-east-1' - - BadRequest = Class.new(StandardError) - Response = Struct.new(:status, :body) - - def initialize(role, params:) - @role = role - @params = params - end - - def execute - api_response = request_from_api! - - Response.new(:ok, api_response.to_hash) - rescue *service_errors - Response.new(:bad_request, {}) - end - - private - - attr_reader :role, :params - - def request_from_api! - case requested_resource - when 'key_pairs' - ec2_client.describe_key_pairs - - when 'instance_types' - instance_types - - when 'roles' - iam_client.list_roles - - when 'regions' - ec2_client.describe_regions - - when 'security_groups' - raise BadRequest unless vpc_id.present? - - ec2_client.describe_security_groups(vpc_filter) - - when 'subnets' - raise BadRequest unless vpc_id.present? - - ec2_client.describe_subnets(vpc_filter) - - when 'vpcs' - ec2_client.describe_vpcs - - else - raise BadRequest - end - end - - def requested_resource - params[:resource] - end - - def vpc_id - params[:vpc_id] - end - - def region - params[:region] || DEFAULT_REGION - end - - def vpc_filter - { - filters: [{ - name: "vpc-id", - values: [vpc_id] - }] - } - end - - ## - # Unfortunately the EC2 API doesn't provide a list of - # possible instance types. There is a workaround, using - # the Pricing API, but instead of requiring the - # user to grant extra permissions for this we use the - # values that validate the CloudFormation template. - def instance_types - { - instance_types: cluster_stack_instance_types.map { |type| Hash(instance_type_name: type) } - } - end - - def cluster_stack_instance_types - YAML.safe_load(stack_template).dig('Parameters', 'NodeInstanceType', 'AllowedValues') - end - - def stack_template - File.read(Rails.root.join('vendor', 'aws', 'cloudformation', 'eks_cluster.yaml')) - end - - def ec2_client - ::Aws::EC2::Client.new(client_options) - end - - def iam_client - ::Aws::IAM::Client.new(client_options) - end - - def credentials - Clusters::Aws::FetchCredentialsService.new(role, region: region).execute - end - - def client_options - { - credentials: credentials, - region: region, - http_open_timeout: 5, - http_read_timeout: 10 - } - end - - def service_errors - [ - BadRequest, - Clusters::Aws::FetchCredentialsService::MissingRoleError, - ::Aws::Errors::MissingCredentialsError, - ::Aws::EC2::Errors::ServiceError, - ::Aws::IAM::Errors::ServiceError, - ::Aws::STS::Errors::ServiceError - ] - end - end - end -end diff --git a/app/services/clusters/cleanup/app_service.rb b/app/services/clusters/cleanup/app_service.rb new file mode 100644 index 00000000000..a7e29c78ea0 --- /dev/null +++ b/app/services/clusters/cleanup/app_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Clusters + module Cleanup + class AppService < Clusters::Cleanup::BaseService + def execute + persisted_applications = @cluster.persisted_applications + + persisted_applications.each do |app| + next unless app.available? + next unless app.can_uninstall? + + log_event(:uninstalling_app, application: app.class.application_name) + uninstall_app_async(app) + end + + # Keep calling the worker untill all dependencies are uninstalled + return schedule_next_execution(Clusters::Cleanup::AppWorker) if persisted_applications.any? + + log_event(:schedule_remove_project_namespaces) + cluster.continue_cleanup! + end + + private + + def uninstall_app_async(application) + application.make_scheduled! + + Clusters::Applications::UninstallWorker.perform_async(application.name, application.id) + end + end + end +end diff --git a/app/services/clusters/cleanup/base_service.rb b/app/services/clusters/cleanup/base_service.rb new file mode 100644 index 00000000000..f99e54cfc40 --- /dev/null +++ b/app/services/clusters/cleanup/base_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Clusters + module Cleanup + class BaseService + DEFAULT_EXECUTION_INTERVAL = 1.minute + + def initialize(cluster, execution_count = 0) + @cluster = cluster + @execution_count = execution_count + end + + private + + attr_reader :cluster + + def logger + @logger ||= Gitlab::Kubernetes::Logger.build + end + + def log_event(event, extra_data = {}) + meta = { + service: self.class.name, + cluster_id: cluster.id, + execution_count: @execution_count, + event: event + } + + logger.info(meta.merge(extra_data)) + end + + def schedule_next_execution(worker_class) + log_event(:scheduling_execution, next_execution: @execution_count + 1) + worker_class.perform_in(execution_interval, cluster.id, @execution_count + 1) + end + + # Override this method to customize the execution interval + def execution_interval + DEFAULT_EXECUTION_INTERVAL + end + end + end +end diff --git a/app/services/clusters/cleanup/project_namespace_service.rb b/app/services/clusters/cleanup/project_namespace_service.rb new file mode 100644 index 00000000000..7621be565ff --- /dev/null +++ b/app/services/clusters/cleanup/project_namespace_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Clusters + module Cleanup + class ProjectNamespaceService < BaseService + KUBERNETES_NAMESPACE_BATCH_SIZE = 100 + + def execute + delete_project_namespaces_in_batches + + # Keep calling the worker untill all namespaces are deleted + if cluster.kubernetes_namespaces.exists? + return schedule_next_execution(Clusters::Cleanup::ProjectNamespaceWorker) + end + + cluster.continue_cleanup! + end + + private + + def delete_project_namespaces_in_batches + kubernetes_namespaces_batch = cluster.kubernetes_namespaces.first(KUBERNETES_NAMESPACE_BATCH_SIZE) + + kubernetes_namespaces_batch.each do |kubernetes_namespace| + log_event(:deleting_project_namespace, namespace: kubernetes_namespace.namespace) + + begin + kubeclient_delete_namespace(kubernetes_namespace) + rescue Kubeclient::HttpError + next + end + + kubernetes_namespace.destroy! + end + end + + def kubeclient_delete_namespace(kubernetes_namespace) + cluster.kubeclient.delete_namespace(kubernetes_namespace.namespace) + rescue Kubeclient::ResourceNotFoundError + # no-op: nothing to delete + end + end + end +end diff --git a/app/services/clusters/cleanup/service_account_service.rb b/app/services/clusters/cleanup/service_account_service.rb new file mode 100644 index 00000000000..d60bd76d388 --- /dev/null +++ b/app/services/clusters/cleanup/service_account_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Clusters + module Cleanup + class ServiceAccountService < BaseService + def execute + delete_gitlab_service_account + + log_event(:destroying_cluster) + + cluster.destroy! + end + + private + + def delete_gitlab_service_account + log_event(:deleting_gitlab_service_account) + + cluster.kubeclient.delete_service_account( + ::Clusters::Kubernetes::GITLAB_SERVICE_ACCOUNT_NAME, + ::Clusters::Kubernetes::GITLAB_SERVICE_ACCOUNT_NAMESPACE + ) + rescue Kubeclient::ResourceNotFoundError + end + end + end +end diff --git a/app/services/clusters/kubernetes/kubernetes.rb b/app/services/clusters/kubernetes.rb index d29519999b2..59cb1c4b3a9 100644 --- a/app/services/clusters/kubernetes/kubernetes.rb +++ b/app/services/clusters/kubernetes.rb @@ -12,5 +12,8 @@ module Clusters GITLAB_KNATIVE_SERVING_ROLE_BINDING_NAME = 'gitlab-knative-serving-rolebinding' GITLAB_CROSSPLANE_DATABASE_ROLE_NAME = 'gitlab-crossplane-database-role' GITLAB_CROSSPLANE_DATABASE_ROLE_BINDING_NAME = 'gitlab-crossplane-database-rolebinding' + GITLAB_KNATIVE_VERSION_ROLE_NAME = 'gitlab-knative-version-role' + GITLAB_KNATIVE_VERSION_ROLE_BINDING_NAME = 'gitlab-knative-version-rolebinding' + KNATIVE_SERVING_NAMESPACE = 'knative-serving' end end diff --git a/app/services/clusters/kubernetes/create_or_update_service_account_service.rb b/app/services/clusters/kubernetes/create_or_update_service_account_service.rb index d798dcdcfd3..046046bf5a3 100644 --- a/app/services/clusters/kubernetes/create_or_update_service_account_service.rb +++ b/app/services/clusters/kubernetes/create_or_update_service_account_service.rb @@ -49,8 +49,14 @@ module Clusters create_or_update_knative_serving_role create_or_update_knative_serving_role_binding + create_or_update_crossplane_database_role create_or_update_crossplane_database_role_binding + + return unless knative_serving_namespace + + create_or_update_knative_version_role + create_or_update_knative_version_role_binding end private @@ -64,6 +70,12 @@ module Clusters ).ensure_exists! end + def knative_serving_namespace + kubeclient.get_namespace(Clusters::Kubernetes::KNATIVE_SERVING_NAMESPACE) + rescue Kubeclient::ResourceNotFoundError + nil + end + def create_role_or_cluster_role_binding if namespace_creator kubeclient.create_or_update_role_binding(role_binding_resource) @@ -88,6 +100,14 @@ module Clusters kubeclient.update_role_binding(crossplane_database_role_binding_resource) end + def create_or_update_knative_version_role + kubeclient.update_cluster_role(knative_version_role_resource) + end + + def create_or_update_knative_version_role_binding + kubeclient.update_cluster_role_binding(knative_version_role_binding_resource) + end + def service_account_resource Gitlab::Kubernetes::ServiceAccount.new( service_account_name, @@ -166,6 +186,27 @@ module Clusters service_account_name: service_account_name ).generate end + + def knative_version_role_resource + Gitlab::Kubernetes::ClusterRole.new( + name: Clusters::Kubernetes::GITLAB_KNATIVE_VERSION_ROLE_NAME, + rules: [{ + apiGroups: %w(apps), + resources: %w(deployments), + verbs: %w(list get) + }] + ).generate + end + + def knative_version_role_binding_resource + subjects = [{ kind: 'ServiceAccount', name: service_account_name, namespace: service_account_namespace }] + + Gitlab::Kubernetes::ClusterRoleBinding.new( + Clusters::Kubernetes::GITLAB_KNATIVE_VERSION_ROLE_BINDING_NAME, + Clusters::Kubernetes::GITLAB_KNATIVE_VERSION_ROLE_NAME, + subjects + ).generate + end end end end diff --git a/app/services/cohorts_service.rb b/app/services/cohorts_service.rb index dbbe89ef260..03be87f4cc1 100644 --- a/app/services/cohorts_service.rb +++ b/app/services/cohorts_service.rb @@ -38,7 +38,7 @@ class CohortsService { registration_month: registration_month, - activity_months: activity_months, + activity_months: activity_months[1..-1], total: activity_months.first[:total], inactive: inactive } diff --git a/app/services/commits/commit_patch_service.rb b/app/services/commits/commit_patch_service.rb index 49113c3c691..4fa6c30e901 100644 --- a/app/services/commits/commit_patch_service.rb +++ b/app/services/commits/commit_patch_service.rb @@ -32,7 +32,7 @@ module Commits end def prepare_branch! - branch_result = CreateBranchService.new(project, current_user) + branch_result = ::Branches::CreateService.new(project, current_user) .execute(@branch_name, @start_branch) if branch_result[:status] != :success diff --git a/app/services/commits/create_service.rb b/app/services/commits/create_service.rb index b42494563b2..bd238605ac1 100644 --- a/app/services/commits/create_service.rb +++ b/app/services/commits/create_service.rb @@ -101,7 +101,7 @@ module Commits end def validate_new_branch_name! - result = ValidateNewBranchService.new(project, current_user).execute(@branch_name, force: force?) + result = ::Branches::ValidateNewService.new(project).execute(@branch_name, force: force?) if result[:status] == :error raise_error("Something went wrong when we tried to create '#{@branch_name}' for you: #{result[:message]}") diff --git a/app/services/concerns/users/participable_service.rb b/app/services/concerns/users/participable_service.rb index 1c828234f1b..6fde9abfdb0 100644 --- a/app/services/concerns/users/participable_service.rb +++ b/app/services/concerns/users/participable_service.rb @@ -55,7 +55,8 @@ module Users username: group.full_path, name: group.full_name, avatar_url: group.avatar_url, - count: group_counts.fetch(group.id, 0) + count: group_counts.fetch(group.id, 0), + mentionsDisabled: group.mentions_disabled } end end diff --git a/app/services/create_branch_service.rb b/app/services/create_branch_service.rb deleted file mode 100644 index d58cb0f9e2b..00000000000 --- a/app/services/create_branch_service.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -class CreateBranchService < BaseService - def execute(branch_name, ref, create_master_if_empty: true) - create_master_branch if create_master_if_empty && project.empty_repo? - - result = ValidateNewBranchService.new(project, current_user) - .execute(branch_name) - - return result if result[:status] == :error - - new_branch = repository.add_branch(current_user, branch_name, ref) - - if new_branch - success(new_branch) - else - error("Invalid reference name: #{branch_name}") - end - rescue Gitlab::Git::PreReceiveError => ex - error(ex.message) - end - - def success(branch) - super().merge(branch: branch) - end - - private - - def create_master_branch - project.repository.create_file( - current_user, - '/README.md', - '', - message: 'Add README.md', - branch_name: 'master' - ) - end -end diff --git a/app/services/create_snippet_service.rb b/app/services/create_snippet_service.rb index 0aa76df35ba..eacea7d94c7 100644 --- a/app/services/create_snippet_service.rb +++ b/app/services/create_snippet_service.rb @@ -21,7 +21,11 @@ class CreateSnippetService < BaseService spam_check(snippet, current_user) - if snippet.save + snippet_saved = snippet.with_transaction_returning_status do + snippet.save && snippet.store_mentions! + end + + if snippet_saved UserAgentDetailService.new(snippet, @request).create Gitlab::UsageDataCounters::SnippetCounter.count(:create) end diff --git a/app/services/delete_branch_service.rb b/app/services/delete_branch_service.rb deleted file mode 100644 index fd41ce54486..00000000000 --- a/app/services/delete_branch_service.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -class DeleteBranchService < BaseService - def execute(branch_name) - repository = project.repository - branch = repository.find_branch(branch_name) - - unless current_user.can?(:push_code, project) - return ServiceResponse.error( - message: 'You dont have push access to repo', - http_status: 405) - end - - unless branch - return ServiceResponse.error( - message: 'No such branch', - http_status: 404) - end - - if repository.rm_branch(current_user, branch_name) - ServiceResponse.success(message: 'Branch was deleted') - else - ServiceResponse.error( - message: 'Failed to remove branch', - http_status: 400) - end - rescue Gitlab::Git::PreReceiveError => ex - ServiceResponse.error(message: ex.message, http_status: 400) - end -end diff --git a/app/services/delete_merged_branches_service.rb b/app/services/delete_merged_branches_service.rb deleted file mode 100644 index 80de897e94b..00000000000 --- a/app/services/delete_merged_branches_service.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -class DeleteMergedBranchesService < BaseService - def async_execute - DeleteMergedBranchesWorker.perform_async(project.id, current_user.id) - end - - def execute - raise Gitlab::Access::AccessDeniedError unless can?(current_user, :push_code, project) - - branches = project.repository.merged_branch_names - # Prevent deletion of branches relevant to open merge requests - branches -= merge_request_branch_names - # Prevent deletion of protected branches - branches = branches.reject { |branch| ProtectedBranch.protected?(project, branch) } - - branches.each do |branch| - DeleteBranchService.new(project, current_user).execute(branch) - end - end - - private - - # rubocop: disable CodeReuse/ActiveRecord - def merge_request_branch_names - # reorder(nil) is necessary for SELECT DISTINCT because default scope adds an ORDER BY - source_names = project.origin_merge_requests.opened.reorder(nil).distinct.pluck(:source_branch) - target_names = project.merge_requests.opened.reorder(nil).distinct.pluck(:target_branch) - (source_names + target_names).uniq - end - # rubocop: enable CodeReuse/ActiveRecord -end diff --git a/app/services/deployments/after_create_service.rb b/app/services/deployments/after_create_service.rb index e0a4e5419cc..1d9cb666cff 100644 --- a/app/services/deployments/after_create_service.rb +++ b/app/services/deployments/after_create_service.rb @@ -29,6 +29,7 @@ module Deployments environment.external_url = url end + renew_auto_stop_in environment.fire_state_event(action) if environment.save && !environment.stopped? @@ -63,6 +64,12 @@ module Deployments def action environment_options[:action] || 'start' end + + def renew_auto_stop_in + return unless deployable + + environment.auto_stop_in = deployable.environment_auto_stop_in + end end end diff --git a/app/services/deployments/create_service.rb b/app/services/deployments/create_service.rb index 89e3f7c8b83..7355747d778 100644 --- a/app/services/deployments/create_service.rb +++ b/app/services/deployments/create_service.rb @@ -11,15 +11,17 @@ module Deployments end def execute - create_deployment.tap do |deployment| - AfterCreateService.new(deployment).execute if deployment.persisted? + environment.deployments.build(deployment_attributes).tap do |deployment| + # Deployment#change_status already saves the model, so we only need to + # call #save ourselves if no status is provided. + if (status = params[:status]) + deployment.update_status(status) + else + deployment.save + end end end - def create_deployment - environment.deployments.create(deployment_attributes) - end - def deployment_attributes # We use explicit parameters here so we never by accident allow parameters # to be set that one should not be able to set (e.g. the row ID). @@ -31,8 +33,7 @@ module Deployments tag: params[:tag], sha: params[:sha], user: current_user, - on_stop: params[:on_stop], - status: params[:status] + on_stop: params[:on_stop] } end end diff --git a/app/services/deployments/update_service.rb b/app/services/deployments/update_service.rb index 97b233f16a7..b8f8740c9b9 100644 --- a/app/services/deployments/update_service.rb +++ b/app/services/deployments/update_service.rb @@ -10,22 +10,7 @@ module Deployments end def execute - # A regular update() does not trigger the state machine transitions, which - # we need to ensure merge requests are linked when changing the status to - # success. To work around this we use this case statment, using the right - # event methods to trigger the transition hooks. - case params[:status] - when 'running' - deployment.run - when 'success' - deployment.succeed - when 'failed' - deployment.drop - when 'canceled' - deployment.cancel - else - false - end + deployment.update_status(params[:status]) end end end diff --git a/app/services/environments/reset_auto_stop_service.rb b/app/services/environments/reset_auto_stop_service.rb new file mode 100644 index 00000000000..237629fda79 --- /dev/null +++ b/app/services/environments/reset_auto_stop_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Environments + class ResetAutoStopService < ::BaseService + def execute(environment) + return error(_('Failed to cancel auto stop because you do not have permission to update the environment.')) unless can_update_environment?(environment) + return error(_('Failed to cancel auto stop because the environment is not set as auto stop.')) unless environment.auto_stop_at? + + if environment.reset_auto_stop + success + else + error(_('Failed to cancel auto stop because failed to update the environment.')) + end + end + + private + + def can_update_environment?(environment) + can?(current_user, :update_environment, environment) + end + end +end diff --git a/app/services/error_tracking/list_issues_service.rb b/app/services/error_tracking/list_issues_service.rb index 2e8c401b8ef..132e9dfa7bd 100644 --- a/app/services/error_tracking/list_issues_service.rb +++ b/app/services/error_tracking/list_issues_service.rb @@ -4,6 +4,7 @@ module ErrorTracking class ListIssuesService < ErrorTracking::BaseService DEFAULT_ISSUE_STATUS = 'unresolved' DEFAULT_LIMIT = 20 + DEFAULT_SORT = 'last_seen' def external_url project_error_tracking_setting&.sentry_external_url @@ -12,11 +13,17 @@ module ErrorTracking private def fetch - project_error_tracking_setting.list_sentry_issues(issue_status: issue_status, limit: limit) + project_error_tracking_setting.list_sentry_issues( + issue_status: issue_status, + limit: limit, + search_term: params[:search_term].presence, + sort: sort, + cursor: params[:cursor].presence + ) end def parse_response(response) - { issues: response[:issues] } + response.slice(:issues, :pagination) end def issue_status @@ -26,5 +33,9 @@ module ErrorTracking def limit params[:limit] || DEFAULT_LIMIT end + + def sort + params[:sort] || DEFAULT_SORT + end end end diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb index 0801fd4d03f..d935d9e8cdc 100644 --- a/app/services/git/base_hooks_service.rb +++ b/app/services/git/base_hooks_service.rb @@ -85,12 +85,36 @@ module Git before: oldrev, after: newrev, ref: ref, + variables_attributes: generate_vars_from_push_options || [], push_options: params[:push_options] || {}, checkout_sha: Gitlab::DataBuilder::Push.checkout_sha( project.repository, newrev, ref) } end + def ci_variables_from_push_options + strong_memoize(:ci_variables_from_push_options) do + params[:push_options]&.deep_symbolize_keys&.dig(:ci, :variable) + end + end + + def generate_vars_from_push_options + return [] unless ci_variables_from_push_options + + ci_variables_from_push_options.map do |var_definition, _count| + key, value = var_definition.to_s.split("=", 2) + + # Accept only valid format. We ignore the following formats + # 1. "=123". In this case, `key` will be an empty string + # 2. "FOO". In this case, `value` will be nil. + # However, the format "FOO=" will result in key beign `FOO` and value + # being an empty string. This is acceptable. + next if key.blank? || value.nil? + + { "key" => key, "variable_type" => "env_var", "secret_value" => value } + end.compact + end + def push_data_params(commits:, with_changed_files: true) { oldrev: oldrev, diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb index 273a12f386a..bbb3c2ad050 100644 --- a/app/services/issuable/bulk_update_service.rb +++ b/app/services/issuable/bulk_update_service.rb @@ -4,19 +4,18 @@ module Issuable class BulkUpdateService include Gitlab::Allowable - attr_accessor :current_user, :params + attr_accessor :parent, :current_user, :params - def initialize(user = nil, params = {}) - @current_user, @params = user, params.dup + def initialize(parent, user = nil, params = {}) + @parent, @current_user, @params = parent, user, params.dup end - # rubocop: disable CodeReuse/ActiveRecord def execute(type) model_class = type.classify.constantize update_class = type.classify.pluralize.constantize::UpdateService ids = params.delete(:issuable_ids).split(",") - items = model_class.where(id: ids) + items = find_issuables(parent, model_class, ids) permitted_attrs(type).each do |key| params.delete(key) unless params[key].present? @@ -37,7 +36,6 @@ module Issuable success: !items.count.zero? } end - # rubocop: enable CodeReuse/ActiveRecord private @@ -50,5 +48,15 @@ module Issuable attrs.push(:assignee_id) end end + + def find_issuables(parent, model_class, ids) + if parent.is_a?(Project) + model_class.id_in(ids).of_projects(parent) + elsif parent.is_a?(Group) + model_class.id_in(ids).of_projects(parent.all_projects) + end + end end end + +Issuable::BulkUpdateService.prepend_if_ee('EE::Issuable::BulkUpdateService') diff --git a/app/services/issuable/clone/attributes_rewriter.rb b/app/services/issuable/clone/attributes_rewriter.rb index 10c89c62bf1..1f5d83917cc 100644 --- a/app/services/issuable/clone/attributes_rewriter.rb +++ b/app/services/issuable/clone/attributes_rewriter.rb @@ -10,7 +10,13 @@ module Issuable end def execute - new_entity.update(milestone: cloneable_milestone, labels: cloneable_labels) + update_attributes = { labels: cloneable_labels } + + milestone = cloneable_milestone + update_attributes[:milestone] = milestone if milestone.present? + + new_entity.update(update_attributes) + copy_resource_label_events end diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb index a170a4dcae2..846b881e819 100644 --- a/app/services/issuable/common_system_notes_service.rb +++ b/app/services/issuable/common_system_notes_service.rb @@ -7,20 +7,24 @@ module Issuable def execute(issuable, old_labels: [], is_update: true) @issuable = issuable - if is_update - if issuable.previous_changes.include?('title') - create_title_change_note(issuable.previous_changes['title'].first) + # We disable touch so that created system notes do not update + # the noteable's updated_at field + ActiveRecord::Base.no_touching do + if is_update + if issuable.previous_changes.include?('title') + create_title_change_note(issuable.previous_changes['title'].first) + end + + handle_description_change_note + + handle_time_tracking_note if issuable.is_a?(TimeTrackable) + create_discussion_lock_note if issuable.previous_changes.include?('discussion_locked') end - handle_description_change_note - - handle_time_tracking_note if issuable.is_a?(TimeTrackable) - create_discussion_lock_note if issuable.previous_changes.include?('discussion_locked') + create_due_date_note if issuable.previous_changes.include?('due_date') + create_milestone_note if issuable.previous_changes.include?('milestone_id') + create_labels_note(old_labels) if old_labels && issuable.labels != old_labels end - - create_due_date_note if issuable.previous_changes.include?('due_date') - create_milestone_note if issuable.previous_changes.include?('milestone_id') - create_labels_note(old_labels) if old_labels && issuable.labels != old_labels end private diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 8a79c5f889d..6cb84458d9b 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -163,10 +163,12 @@ class IssuableBaseService < BaseService before_create(issuable) - if issuable.save - ActiveRecord::Base.no_touching do - Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, is_update: false) - end + issuable_saved = issuable.with_transaction_returning_status do + issuable.save && issuable.store_mentions! + end + + if issuable_saved + Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, is_update: false) after_create(issuable) execute_hooks(issuable) @@ -226,11 +228,12 @@ class IssuableBaseService < BaseService update_project_counters = issuable.project && update_project_counter_caches?(issuable) ensure_milestone_available(issuable) - if issuable.with_transaction_returning_status { issuable.save(touch: should_touch) } - # We do not touch as it will affect a update on updated_at field - ActiveRecord::Base.no_touching do - Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: old_associations[:labels]) - end + issuable_saved = issuable.with_transaction_returning_status do + issuable.save(touch: should_touch) && issuable.store_mentions! + end + + if issuable_saved + Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: old_associations[:labels]) handle_changes(issuable, old_associations: old_associations) @@ -264,10 +267,7 @@ class IssuableBaseService < BaseService before_update(issuable, skip_spam_check: true) if issuable.with_transaction_returning_status { issuable.save } - # We do not touch as it will affect a update on updated_at field - ActiveRecord::Base.no_touching do - Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: nil) - end + Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: nil) handle_task_changes(issuable) invalidate_cache_counts(issuable, users: issuable.assignees.to_a) @@ -397,7 +397,7 @@ class IssuableBaseService < BaseService end def update_project_counter_caches?(issuable) - issuable.state_changed? + issuable.state_id_changed? end def parent diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index 48ed5afbc2a..974f7e598ca 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -36,3 +36,5 @@ module Issues end end end + +Issues::BaseService.prepend_if_ee('EE::Issues::BaseService') diff --git a/app/services/issues/duplicate_service.rb b/app/services/issues/duplicate_service.rb index 82c226f601e..c936d75e277 100644 --- a/app/services/issues/duplicate_service.rb +++ b/app/services/issues/duplicate_service.rb @@ -25,3 +25,5 @@ module Issues end end end + +Issues::DuplicateService.prepend_if_ee('EE::Issues::DuplicateService') diff --git a/app/services/issues/zoom_link_service.rb b/app/services/issues/zoom_link_service.rb index 023d7080e88..9572cf50564 100644 --- a/app/services/issues/zoom_link_service.rb +++ b/app/services/issues/zoom_link_service.rb @@ -13,30 +13,29 @@ module Issues if can_add_link? && (link = parse_link(link)) begin add_zoom_meeting(link) - success(_('Zoom meeting added')) rescue ActiveRecord::RecordNotUnique - error(_('Failed to add a Zoom meeting')) + error(message: _('Failed to add a Zoom meeting')) end else - error(_('Failed to add a Zoom meeting')) + error(message: _('Failed to add a Zoom meeting')) end end def remove_link if can_remove_link? remove_zoom_meeting - success(_('Zoom meeting removed')) + success(message: _('Zoom meeting removed')) else - error(_('Failed to remove a Zoom meeting')) + error(message: _('Failed to remove a Zoom meeting')) end end def can_add_link? - can_update_issue? && !@added_meeting + can_change_link? && !@added_meeting end def can_remove_link? - can_update_issue? && !!@added_meeting + can_change_link? && @issue.persisted? && !!@added_meeting end def parse_link(link) @@ -56,14 +55,29 @@ module Issues end def add_zoom_meeting(link) - ZoomMeeting.create( + zoom_meeting = new_zoom_meeting(link) + response = + if @issue.persisted? + # Save the meeting directly since we only want to update one meeting, not all + zoom_meeting.save + success(message: _('Zoom meeting added')) + else + success(message: _('Zoom meeting added'), payload: { zoom_meetings: [zoom_meeting] }) + end + + track_meeting_added_event + SystemNoteService.zoom_link_added(@issue, @project, current_user) + + response + end + + def new_zoom_meeting(link) + ZoomMeeting.new( issue: @issue, - project: @issue.project, + project: @project, issue_status: :added, url: link ) - track_meeting_added_event - SystemNoteService.zoom_link_added(@issue, @project, current_user) end def remove_zoom_meeting @@ -72,16 +86,20 @@ module Issues SystemNoteService.zoom_link_removed(@issue, @project, current_user) end - def success(message) - ServiceResponse.success(message: message) + def success(message:, payload: nil) + ServiceResponse.success(message: message, payload: payload) end - def error(message) + def error(message:) ServiceResponse.error(message: message) end - def can_update_issue? - can?(current_user, :update_issue, project) + def can_change_link? + if @issue.persisted? + can?(current_user, :update_issue, @project) + else + can?(current_user, :create_issue, @project) + end end end end diff --git a/app/services/merge_requests/create_from_issue_service.rb b/app/services/merge_requests/create_from_issue_service.rb index 200a34cae04..95fb99d3e7a 100644 --- a/app/services/merge_requests/create_from_issue_service.rb +++ b/app/services/merge_requests/create_from_issue_service.rb @@ -19,7 +19,7 @@ module MergeRequests return error('Not allowed to create merge request') unless can_create_merge_request? return error('Invalid issue iid') unless @issue_iid.present? && issue.present? - result = CreateBranchService.new(target_project, current_user).execute(branch_name, ref) + result = ::Branches::CreateService.new(target_project, current_user).execute(branch_name, ref) return result if result[:status] == :error new_merge_request = create(merge_request) diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index a45b4f1142e..4a109fe4e16 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -62,8 +62,6 @@ module MergeRequests end def updated_check! - return unless Feature.enabled?(:validate_merge_sha, merge_request.target_project, default_enabled: false) - unless source_matches? raise_error('Branch has been updated since the merge was requested. '\ 'Please review the changes.') @@ -101,7 +99,7 @@ module MergeRequests log_info("Post merge finished on JID #{merge_jid} with state #{state}") if delete_source_branch? - DeleteBranchService.new(@merge_request.source_project, branch_deletion_user) + ::Branches::DeleteService.new(@merge_request.source_project, branch_deletion_user) .execute(merge_request.source_branch) end end diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index bd3fcf85a62..396ddec6383 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -106,7 +106,7 @@ module MergeRequests filter_merge_requests(merge_requests).each do |merge_request| if branch_and_project_match?(merge_request) || @push.force_push? merge_request.reload_diff(current_user) - elsif merge_request.includes_any_commits?(push_commit_ids) + elsif merge_request.merge_request_diff.includes_any_commits?(push_commit_ids) merge_request.reload_diff(current_user) end diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 8a6a7119508..1dc5503d368 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -87,7 +87,7 @@ module MergeRequests merge_request.update(merge_error: nil) - if merge_request.head_pipeline && merge_request.head_pipeline.active? + if merge_request.head_pipeline_active? AutoMergeService.new(project, current_user, { sha: last_diff_sha }).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) else merge_request.merge_async(current_user.id, { sha: last_diff_sha }) diff --git a/app/services/metrics/dashboard/base_embed_service.rb b/app/services/metrics/dashboard/base_embed_service.rb index 8bb5f4892cb..8aef9873ac1 100644 --- a/app/services/metrics/dashboard/base_embed_service.rb +++ b/app/services/metrics/dashboard/base_embed_service.rb @@ -13,7 +13,7 @@ module Metrics def dashboard_path params[:dashboard_path].presence || - ::Metrics::Dashboard::SystemDashboardService::SYSTEM_DASHBOARD_PATH + ::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH end def group diff --git a/app/services/metrics/dashboard/custom_metric_embed_service.rb b/app/services/metrics/dashboard/custom_metric_embed_service.rb index 79a556b1695..9e616f4e379 100644 --- a/app/services/metrics/dashboard/custom_metric_embed_service.rb +++ b/app/services/metrics/dashboard/custom_metric_embed_service.rb @@ -40,7 +40,7 @@ module Metrics # All custom metrics are displayed on the system dashboard. # Nil is acceptable as we'll default to the system dashboard. def valid_dashboard?(dashboard) - dashboard.nil? || ::Metrics::Dashboard::SystemDashboardService.system_dashboard?(dashboard) + dashboard.nil? || ::Metrics::Dashboard::SystemDashboardService.matching_dashboard?(dashboard) end end diff --git a/app/services/metrics/dashboard/grafana_metric_embed_service.rb b/app/services/metrics/dashboard/grafana_metric_embed_service.rb index 60591e9a6f3..44b58ad9729 100644 --- a/app/services/metrics/dashboard/grafana_metric_embed_service.rb +++ b/app/services/metrics/dashboard/grafana_metric_embed_service.rb @@ -133,7 +133,7 @@ module Metrics def uid_regex base_url = @project.grafana_integration.grafana_url.chomp('/') - %r{(#{Regexp.escape(base_url)}\/d\/(?<uid>\w+)\/)}x + %r{^(#{Regexp.escape(base_url)}\/d\/(?<uid>.+)\/)}x end end diff --git a/app/services/metrics/dashboard/pod_dashboard_service.rb b/app/services/metrics/dashboard/pod_dashboard_service.rb new file mode 100644 index 00000000000..16b87d2d587 --- /dev/null +++ b/app/services/metrics/dashboard/pod_dashboard_service.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Metrics + module Dashboard + class PodDashboardService < ::Metrics::Dashboard::PredefinedDashboardService + DASHBOARD_PATH = 'config/prometheus/pod_metrics.yml' + DASHBOARD_NAME = 'Pod Health' + end + end +end diff --git a/app/services/metrics/dashboard/predefined_dashboard_service.rb b/app/services/metrics/dashboard/predefined_dashboard_service.rb new file mode 100644 index 00000000000..1be1a000854 --- /dev/null +++ b/app/services/metrics/dashboard/predefined_dashboard_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Metrics + module Dashboard + class PredefinedDashboardService < ::Metrics::Dashboard::BaseService + # These constants should be overridden in the inheriting class. For Ex: + # DASHBOARD_PATH = 'config/prometheus/common_metrics.yml' + # DASHBOARD_NAME = 'Default' + DASHBOARD_PATH = nil + DASHBOARD_NAME = nil + + SEQUENCE = [ + STAGES::EndpointInserter, + STAGES::Sorter + ].freeze + + class << self + def matching_dashboard?(filepath) + filepath == self::DASHBOARD_PATH + end + end + + private + + def cache_key + "metrics_dashboard_#{dashboard_path}" + end + + def dashboard_path + self.class::DASHBOARD_PATH + end + + # Returns the base metrics shipped with every GitLab service. + def get_raw_dashboard + yml = File.read(Rails.root.join(dashboard_path)) + + YAML.safe_load(yml) + end + + def sequence + self.class::SEQUENCE + end + end + end +end diff --git a/app/services/metrics/dashboard/system_dashboard_service.rb b/app/services/metrics/dashboard/system_dashboard_service.rb index f8dbb8a705c..bef65dbe1c2 100644 --- a/app/services/metrics/dashboard/system_dashboard_service.rb +++ b/app/services/metrics/dashboard/system_dashboard_service.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true # Fetches the system metrics dashboard and formats the output. -# Use Gitlab::Metrics::Dashboard::Finder to retrive dashboards. +# Use Gitlab::Metrics::Dashboard::Finder to retrieve dashboards. module Metrics module Dashboard - class SystemDashboardService < ::Metrics::Dashboard::BaseService - SYSTEM_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml' - SYSTEM_DASHBOARD_NAME = 'Default' + class SystemDashboardService < ::Metrics::Dashboard::PredefinedDashboardService + DASHBOARD_PATH = 'config/prometheus/common_metrics.yml' + DASHBOARD_NAME = 'Default' SEQUENCE = [ STAGES::CommonMetricsInserter, @@ -18,37 +18,12 @@ module Metrics class << self def all_dashboard_paths(_project) [{ - path: SYSTEM_DASHBOARD_PATH, - display_name: SYSTEM_DASHBOARD_NAME, + path: DASHBOARD_PATH, + display_name: DASHBOARD_NAME, default: true, system_dashboard: true }] end - - def system_dashboard?(filepath) - filepath == SYSTEM_DASHBOARD_PATH - end - end - - private - - def cache_key - "metrics_dashboard_#{dashboard_path}" - end - - def dashboard_path - SYSTEM_DASHBOARD_PATH - end - - # Returns the base metrics shipped with every GitLab service. - def get_raw_dashboard - yml = File.read(Rails.root.join(dashboard_path)) - - YAML.safe_load(yml) - end - - def sequence - SEQUENCE end end end diff --git a/app/services/metrics/sample_metrics_service.rb b/app/services/metrics/sample_metrics_service.rb new file mode 100644 index 00000000000..719bc6614e4 --- /dev/null +++ b/app/services/metrics/sample_metrics_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Metrics + class SampleMetricsService + DIRECTORY = "sample_metrics" + + attr_reader :identifier + + def initialize(identifier) + @identifier = identifier + end + + def query + return unless identifier && File.exist?(file_location) + + YAML.load_file(File.expand_path(file_location, __dir__)) + end + + private + + def file_location + sanitized_string = identifier.gsub(/[^0-9A-Za-z_]/, '') + File.join(Rails.root, DIRECTORY, "#{sanitized_string}.yml") + end + end +end diff --git a/app/services/notes/base_service.rb b/app/services/notes/base_service.rb index b4d04c47cc0..87f7cb0e8ac 100644 --- a/app/services/notes/base_service.rb +++ b/app/services/notes/base_service.rb @@ -4,7 +4,7 @@ module Notes class BaseService < ::BaseService def clear_noteable_diffs_cache(note) if note.is_a?(DiffNote) && - note.discussion_first_note? && + note.start_of_discussion? && note.position.unfolded_diff?(project.repository) note.noteable.diffs.clear_cache end diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb index 541f3e0d23c..cf21818a886 100644 --- a/app/services/notes/build_service.rb +++ b/app/services/notes/build_service.rb @@ -11,7 +11,7 @@ module Notes unless discussion && can?(current_user, :create_note, discussion.noteable) note = Note.new - note.errors.add(:base, 'Discussion to reply to cannot be found') + note.errors.add(:base, _('Discussion to reply to cannot be found')) return note end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 9e6cbfa06fe..accfdb5b863 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -2,6 +2,7 @@ module Notes class CreateService < ::Notes::BaseService + # rubocop:disable Metrics/CyclomaticComplexity def execute merge_request_diff_head_sha = params.delete(:merge_request_diff_head_sha) @@ -9,7 +10,9 @@ module Notes # n+1: https://gitlab.com/gitlab-org/gitlab-foss/issues/37440 note_valid = Gitlab::GitalyClient.allow_n_plus_1_calls do - note.valid? + # We may set errors manually in Notes::BuildService for this reason + # we also need to check for already existing errors. + note.errors.empty? && note.valid? end return note unless note_valid @@ -33,7 +36,11 @@ module Notes NewNoteWorker.perform_async(note.id) end - if !only_commands && note.save + note_saved = note.with_transaction_returning_status do + !only_commands && note.save && note.store_mentions! + end + + if note_saved if note.part_of_discussion? && note.discussion.can_convert_to_discussion? note.discussion.convert_to_discussion!(save: true) end @@ -63,6 +70,7 @@ module Notes note end + # rubocop:enable Metrics/CyclomaticComplexity private diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb index 573be8fbe8b..15c556498ec 100644 --- a/app/services/notes/update_service.rb +++ b/app/services/notes/update_service.rb @@ -7,7 +7,11 @@ module Notes old_mentioned_users = note.mentioned_users(current_user).to_a - note.update(params.merge(updated_by: current_user)) + note.assign_attributes(params.merge(updated_by: current_user)) + + note.with_transaction_returning_status do + note.save && note.store_mentions! + end only_commands = false diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 1709474a6c7..a75eaa99c23 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -58,6 +58,14 @@ class NotificationService end end + # Notify the owner of the personal access token, when it is about to expire + # And mark the token with about_to_expire_delivered + def access_token_about_to_expire(user) + return unless user.can?(:receive_notifications) + + mailer.access_token_about_to_expire_email(user).deliver_later + end + # When create an issue we should send an email to: # # * issue assignee if their notification level is not Disabled diff --git a/app/services/pages/delete_service.rb b/app/services/pages/delete_service.rb new file mode 100644 index 00000000000..d4de6bb750d --- /dev/null +++ b/app/services/pages/delete_service.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Pages + class DeleteService < BaseService + def execute + project.remove_pages + project.pages_domains.destroy_all # rubocop: disable DestroyAll + end + end +end diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb index 1b880a7aab1..b995df12e56 100644 --- a/app/services/projects/container_repository/cleanup_tags_service.rb +++ b/app/services/projects/container_repository/cleanup_tags_service.rb @@ -26,13 +26,13 @@ module Projects def delete_tags(tags_to_delete, tags_by_digest) deleted_digests = group_by_digest(tags_to_delete).select do |digest, tags| - delete_tag_digest(digest, tags, tags_by_digest[digest]) + delete_tag_digest(tags, tags_by_digest[digest]) end deleted_digests.values.flatten end - def delete_tag_digest(digest, tags, other_tags) + def delete_tag_digest(tags, other_tags) # Issue: https://gitlab.com/gitlab-org/gitlab-foss/issues/21405 # we have to remove all tags due # to Docker Distribution bug unable diff --git a/app/services/projects/container_repository/delete_tags_service.rb b/app/services/projects/container_repository/delete_tags_service.rb index 48bd9394dc5..88ff3c2c9df 100644 --- a/app/services/projects/container_repository/delete_tags_service.rb +++ b/app/services/projects/container_repository/delete_tags_service.rb @@ -24,32 +24,36 @@ module Projects dummy_manifest = container_repository.client.generate_empty_manifest(container_repository.path) return error('could not generate manifest') if dummy_manifest.nil? - # update the manifests of the tags with the new dummy image - deleted_tags = [] - tag_digests = [] + deleted_tags = replace_tag_manifests(container_repository, dummy_manifest, tag_names) + + # Deletes the dummy image + # All created tag digests are the same since they all have the same dummy image. + # a single delete is sufficient to remove all tags with it + if deleted_tags.any? && container_repository.delete_tag_by_digest(deleted_tags.values.first) + success(deleted: deleted_tags.keys) + else + error('could not delete tags') + end + end + + # update the manifests of the tags with the new dummy image + def replace_tag_manifests(container_repository, dummy_manifest, tag_names) + deleted_tags = {} tag_names.each do |name| digest = container_repository.client.put_tag(container_repository.path, name, dummy_manifest) next unless digest - deleted_tags << name - tag_digests << digest + deleted_tags[name] = digest end # make sure the digests are the same (it should always be) - tag_digests.uniq! + digests = deleted_tags.values.uniq # rubocop: disable CodeReuse/ActiveRecord - Gitlab::Sentry.track_exception(ArgumentError.new('multiple tag digests')) if tag_digests.many? + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(ArgumentError.new('multiple tag digests')) if digests.many? - # Deletes the dummy image - # All created tag digests are the same since they all have the same dummy image. - # a single delete is sufficient to remove all tags with it - if tag_digests.any? && container_repository.delete_tag_by_digest(tag_digests.first) - success(deleted: deleted_tags) - else - error('could not delete tags') - end + deleted_tags end end end diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 90e703e7050..cbed794f92e 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -31,13 +31,6 @@ module Projects Projects::UnlinkForkService.new(project, current_user).execute - # The project is not necessarily a fork, so update the fork network originating - # from this project - if fork_network = project.root_of_fork_network - fork_network.update(root_project: nil, - deleted_root_project_name: project.full_name) - end - attempt_destroy_transaction(project) system_hook_service.execute_hooks_for(project, :destroy) diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb index 47ab7f9a8a0..e66a0ed181a 100644 --- a/app/services/projects/fork_service.rb +++ b/app/services/projects/fork_service.rb @@ -3,11 +3,16 @@ module Projects class ForkService < BaseService def execute(fork_to_project = nil) - if fork_to_project - link_existing_project(fork_to_project) - else - fork_new_project - end + forked_project = + if fork_to_project + link_existing_project(fork_to_project) + else + fork_new_project + end + + refresh_forks_count if forked_project&.saved? + + forked_project end private @@ -92,8 +97,7 @@ module Projects def link_fork_network(fork_to_project) return if fork_to_project.errors.any? - fork_to_project.fork_network_member.save && - refresh_forks_count + fork_to_project.fork_network_member.save end def refresh_forks_count diff --git a/app/services/projects/hashed_storage/base_repository_service.rb b/app/services/projects/hashed_storage/base_repository_service.rb index 8b1bcaf17b7..09de8d9f0da 100644 --- a/app/services/projects/hashed_storage/base_repository_service.rb +++ b/app/services/projects/hashed_storage/base_repository_service.rb @@ -8,13 +8,12 @@ module Projects class BaseRepositoryService < BaseService include Gitlab::ShellAdapter - attr_reader :old_disk_path, :new_disk_path, :old_wiki_disk_path, :old_storage_version, :logger, :move_wiki + attr_reader :old_disk_path, :new_disk_path, :old_storage_version, :logger, :move_wiki def initialize(project:, old_disk_path:, logger: nil) @project = project @logger = logger || Gitlab::AppLogger @old_disk_path = old_disk_path - @old_wiki_disk_path = "#{old_disk_path}.wiki" @move_wiki = has_wiki? end @@ -44,9 +43,21 @@ module Projects gitlab_shell.mv_repository(project.repository_storage, from_name, to_name) end + def move_repositories + result = move_repository(old_disk_path, new_disk_path) + project.reload_repository! + + if move_wiki + result &&= move_repository(old_wiki_disk_path, new_wiki_disk_path) + project.clear_memoization(:wiki) + end + + result + end + def rollback_folder_move move_repository(new_disk_path, old_disk_path) - move_repository("#{new_disk_path}.wiki", old_wiki_disk_path) + move_repository(new_wiki_disk_path, old_wiki_disk_path) end def try_to_set_repository_read_only! @@ -58,6 +69,20 @@ module Projects raise RepositoryInUseError, migration_error end end + + def wiki_path_suffix + @wiki_path_suffix ||= Gitlab::GlRepository::WIKI.path_suffix + end + + def old_wiki_disk_path + @old_wiki_disk_path ||= "#{old_disk_path}#{wiki_path_suffix}" + end + + def new_wiki_disk_path + @new_wiki_disk_path ||= "#{new_disk_path}#{wiki_path_suffix}" + end end end end + +Projects::HashedStorage::BaseRepositoryService.prepend_if_ee('EE::Projects::HashedStorage::BaseRepositoryService') diff --git a/app/services/projects/hashed_storage/migrate_repository_service.rb b/app/services/projects/hashed_storage/migrate_repository_service.rb index 0a0bd90cd20..fd62ac37d27 100644 --- a/app/services/projects/hashed_storage/migrate_repository_service.rb +++ b/app/services/projects/hashed_storage/migrate_repository_service.rb @@ -11,11 +11,7 @@ module Projects @new_disk_path = project.disk_path - result = move_repository(old_disk_path, new_disk_path) - - if move_wiki - result &&= move_repository(old_wiki_disk_path, "#{new_disk_path}.wiki") - end + result = move_repositories if result project.write_repository_config diff --git a/app/services/projects/hashed_storage/rollback_repository_service.rb b/app/services/projects/hashed_storage/rollback_repository_service.rb index a705112ebe3..d6646e3765e 100644 --- a/app/services/projects/hashed_storage/rollback_repository_service.rb +++ b/app/services/projects/hashed_storage/rollback_repository_service.rb @@ -11,11 +11,7 @@ module Projects @new_disk_path = project.disk_path - result = move_repository(old_disk_path, new_disk_path) - - if move_wiki - result &&= move_repository(old_wiki_disk_path, "#{new_disk_path}.wiki") - end + result = move_repositories if result project.write_repository_config diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index 073c14040ce..cc12aacaf02 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -25,13 +25,13 @@ module Projects success rescue Gitlab::UrlBlocker::BlockedUrlError => e - Gitlab::Sentry.track_acceptable_exception(e, extra: { project_path: project.full_path, importer: project.import_type }) + Gitlab::ErrorTracking.track_exception(e, project_path: project.full_path, importer: project.import_type) error(s_("ImportProjects|Error importing repository %{project_safe_import_url} into %{project_full_path} - %{message}") % { project_safe_import_url: project.safe_import_url, project_full_path: project.full_path, message: e.message }) rescue => e message = Projects::ImportErrorFilter.filter_message(e.message) - Gitlab::Sentry.track_acceptable_exception(e, extra: { project_path: project.full_path, importer: project.import_type }) + Gitlab::ErrorTracking.track_exception(e, project_path: project.full_path, importer: project.import_type) error(s_("ImportProjects|Error importing repository %{project_safe_import_url} into %{project_full_path} - %{message}") % { project_safe_import_url: project.safe_import_url, project_full_path: project.full_path, message: message }) end diff --git a/app/services/projects/overwrite_project_service.rb b/app/services/projects/overwrite_project_service.rb index 696e1b665b2..c5e38f166da 100644 --- a/app/services/projects/overwrite_project_service.rb +++ b/app/services/projects/overwrite_project_service.rb @@ -7,7 +7,9 @@ module Projects Project.transaction do move_before_destroy_relationships(source_project) - destroy_old_project(source_project) + # Reset is required in order to get the proper + # uncached fork network method calls value. + destroy_old_project(source_project.reset) rename_project(source_project.name, source_project.path) @project diff --git a/app/services/projects/unlink_fork_service.rb b/app/services/projects/unlink_fork_service.rb index 1b8a920268f..e7e0141099e 100644 --- a/app/services/projects/unlink_fork_service.rb +++ b/app/services/projects/unlink_fork_service.rb @@ -2,34 +2,67 @@ module Projects class UnlinkForkService < BaseService - # rubocop: disable CodeReuse/ActiveRecord + # If a fork is given, it: + # + # - Saves LFS objects to the root project + # - Close existing MRs coming from it + # - Is removed from the fork network + # + # If a root of fork(s) is given, it does the same, + # but not updating LFS objects (there'll be no related root to cache it). def execute - return unless @project.forked? + fork_network = @project.fork_network - if fork_source = @project.fork_source - fork_source.lfs_objects.find_each do |lfs_object| - lfs_object.projects << @project unless lfs_object.projects.include?(@project) - end + return unless fork_network - refresh_forks_count(fork_source) - end + save_lfs_objects - merge_requests = @project.fork_network + merge_requests = fork_network .merge_requests .opened - .where.not(target_project: @project) - .from_project(@project) + .from_and_to_forks(@project) - merge_requests.each do |mr| + merge_requests.find_each do |mr| ::MergeRequests::CloseService.new(@project, @current_user).execute(mr) end - @project.fork_network_member.destroy + Project.transaction do + # Get out of the fork network as a member and + # remove references from all its direct forks. + @project.fork_network_member.destroy + @project.forked_to_members.update_all(forked_from_project_id: nil) + + # The project is not necessarily a fork, so update the fork network originating + # from this project + if fork_network = @project.root_of_fork_network + fork_network.update(root_project: nil, deleted_root_project_name: @project.full_name) + end + end + + # When the project getting out of the network is a node with parent + # and children, both the parent and the node needs a cache refresh. + [@project.forked_from_project, @project].compact.each do |project| + refresh_forks_count(project) + end end - # rubocop: enable CodeReuse/ActiveRecord + + private def refresh_forks_count(project) Projects::ForksCountService.new(project).refresh_cache end + + def save_lfs_objects + return unless @project.forked? + + lfs_storage_project = @project.lfs_storage_project + + return unless lfs_storage_project + return if lfs_storage_project == @project # that project is being unlinked + + lfs_storage_project.lfs_objects.find_each do |lfs_object| + lfs_object.projects << @project unless lfs_object.projects.include?(@project) + end + end end end diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index 2dad1d05a2c..aedd7252f63 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -65,7 +65,7 @@ module Projects ) project_changed_feature_keys = project.project_feature.previous_changes.keys - if project.previous_changes.include?(:visibility_level) && project.private? + if project.visibility_level_previous_changes && project.private? # don't enqueue immediately to prevent todos removal in case of a mistake TodosDestroyer::ConfidentialIssueWorker.perform_in(Todo::WAIT_FOR_DELETE, nil, project.id) TodosDestroyer::ProjectPrivateWorker.perform_in(Todo::WAIT_FOR_DELETE, project.id) @@ -79,6 +79,11 @@ module Projects system_hook_service.execute_hooks_for(project, :update) end + if project.visibility_level_decreased? && project.unlink_forks_upon_visibility_decrease_enabled? + # It's a system-bounded operation, so no extra authorization check is required. + Projects::UnlinkForkService.new(project, current_user).execute + end + update_pages_config if changing_pages_related_config? end diff --git a/app/services/prometheus/proxy_variable_substitution_service.rb b/app/services/prometheus/proxy_variable_substitution_service.rb new file mode 100644 index 00000000000..ca56292e9d6 --- /dev/null +++ b/app/services/prometheus/proxy_variable_substitution_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Prometheus + class ProxyVariableSubstitutionService < BaseService + include Stepable + + steps :add_params_to_result, :substitute_ruby_variables + + def initialize(environment, params = {}) + @environment, @params = environment, params.deep_dup + end + + def execute + execute_steps + end + + private + + def add_params_to_result(result) + result[:params] = params + + success(result) + end + + def substitute_ruby_variables(result) + return success(result) unless query + + # The % operator doesn't replace variables if the hash contains string + # keys. + result[:params][:query] = query % predefined_context.symbolize_keys + + success(result) + rescue TypeError, ArgumentError => exception + log_error(exception.message) + Gitlab::ErrorTracking.track_exception(exception, extra: { + template_string: query, + variables: predefined_context + }) + + error(_('Malformed string')) + end + + def predefined_context + @predefined_context ||= Gitlab::Prometheus::QueryVariables.call(@environment) + end + + def query + params[:query] + end + end +end diff --git a/app/services/repair_ldap_blocked_user_service.rb b/app/services/repair_ldap_blocked_user_service.rb deleted file mode 100644 index 6ed42054ac3..00000000000 --- a/app/services/repair_ldap_blocked_user_service.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -class RepairLdapBlockedUserService - attr_accessor :user - - def initialize(user) - @user = user - end - - def execute - user.block if ldap_hard_blocked? - end - - private - - def ldap_hard_blocked? - user.ldap_blocked? && !user.ldap_user? - end -end diff --git a/app/services/submit_usage_ping_service.rb b/app/services/submit_usage_ping_service.rb index 415a02ab337..7927ab265c5 100644 --- a/app/services/submit_usage_ping_service.rb +++ b/app/services/submit_usage_ping_service.rb @@ -38,7 +38,7 @@ class SubmitUsagePingService def store_metrics(response) return unless response['conv_index'].present? - ConversationalDevelopmentIndex::Metric.create!( + DevOpsScore::Metric.create!( response['conv_index'].slice(*METRICS) ) end diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 2299a02fea1..55f888d5664 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -174,6 +174,19 @@ class TodoService mark_todos_as_done(todos, current_user) end + def mark_all_todos_as_done_by_user(current_user) + todos = TodosFinder.new(current_user).execute + mark_todos_as_done(todos, current_user) + end + + def mark_todo_as_done(todo, current_user) + return if todo.done? + + todo.update(state: :done) + + current_user.update_todos_count_cache + end + # When user marks some todos as pending def mark_todos_as_pending(todos, current_user) update_todos_state(todos, current_user, :pending) diff --git a/app/services/update_snippet_service.rb b/app/services/update_snippet_service.rb index a294812ef9e..ac7f8e9b1f5 100644 --- a/app/services/update_snippet_service.rb +++ b/app/services/update_snippet_service.rb @@ -25,8 +25,12 @@ class UpdateSnippetService < BaseService snippet.assign_attributes(params) spam_check(snippet, current_user) - snippet.save.tap do |succeeded| - Gitlab::UsageDataCounters::SnippetCounter.count(:update) if succeeded + snippet_saved = snippet.with_transaction_returning_status do + snippet.save && snippet.store_mentions! + end + + if snippet_saved + Gitlab::UsageDataCounters::SnippetCounter.count(:update) end end end diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb index 8c85ad9ffd8..ea4d11e728e 100644 --- a/app/services/users/build_service.rb +++ b/app/services/users/build_service.rb @@ -23,7 +23,7 @@ module Users @reset_token = user.generate_reset_token if params[:reset_password] if user_params[:force_random_password] - random_password = Devise.friendly_token.first(Devise.password_length.min) + random_password = Devise.friendly_token.first(User.password_length.min) user.password = user.password_confirmation = random_password end end diff --git a/app/services/users/repair_ldap_blocked_service.rb b/app/services/users/repair_ldap_blocked_service.rb new file mode 100644 index 00000000000..378145a65b3 --- /dev/null +++ b/app/services/users/repair_ldap_blocked_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Users + class RepairLdapBlockedService + attr_accessor :user + + def initialize(user) + @user = user + end + + def execute + user.block if ldap_hard_blocked? + end + + private + + def ldap_hard_blocked? + user.ldap_blocked? && !user.ldap_user? + end + end +end diff --git a/app/services/validate_new_branch_service.rb b/app/services/validate_new_branch_service.rb deleted file mode 100644 index 3f4a59e5cee..00000000000 --- a/app/services/validate_new_branch_service.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -require_relative 'base_service' - -class ValidateNewBranchService < BaseService - def execute(branch_name, force: false) - valid_branch = Gitlab::GitRefValidator.validate(branch_name) - - unless valid_branch - return error('Branch name is invalid') - end - - if project.repository.branch_exists?(branch_name) && !force - return error('Branch already exists') - end - - success - rescue Gitlab::Git::PreReceiveError => ex - error(ex.message) - end -end diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index 8c294218708..87edac36e33 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -92,9 +92,6 @@ class WebHookService end def log_execution(trigger:, url:, request_data:, response:, execution_duration:, error_message: nil) - # logging for ServiceHook's is not available - return if hook.is_a?(ServiceHook) - WebHookLog.create( web_hook: hook, trigger: trigger, diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml index 4358365504a..6b95c0f40c5 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -23,6 +23,9 @@ = f.label :session_expire_delay, _('Session duration (minutes)'), class: 'label-light' = f.number_field :session_expire_delay, class: 'form-control' %span.form-text.text-muted#session_expire_delay_help_block= _('GitLab restart is required to apply changes') + + = render_if_exists 'admin/application_settings/personal_access_token_expiration_policy', form: f + .form-group = f.label :user_oauth_applications, _('User OAuth applications'), class: 'label-bold' .form-check diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index 9806090c1a6..cb9f992bb1d 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -58,6 +58,6 @@ = f.text_field :default_ci_config_path, class: 'form-control', placeholder: '.gitlab-ci.yml' %p.form-text.text-muted = _("The default CI configuration path for new projects.").html_safe - = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'custom-ci-config-path'), target: '_blank' + = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'custom-ci-configuration-path'), target: '_blank' = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/_signup.html.haml b/app/views/admin/application_settings/_signup.html.haml index 7c1df78f30c..b9d9d86ca30 100644 --- a/app/views/admin/application_settings/_signup.html.haml +++ b/app/views/admin/application_settings/_signup.html.haml @@ -13,6 +13,12 @@ = f.label :send_user_confirmation_email, class: 'form-check-label' do Send confirmation email on sign-up .form-group + = f.label :minimum_password_length, _('Minimum password length (number of characters)'), class: 'label-bold' + = f.number_field :minimum_password_length, class: 'form-control', rows: 4, min: ApplicationSetting::DEFAULT_MINIMUM_PASSWORD_LENGTH, max: Devise.password_length.max + - password_policy_guidelines_link = link_to _('Password Policy Guidelines'), 'https://about.gitlab.com/handbook/security/#gitlab-password-policy-guidelines', target: '_blank', rel: 'noopener noreferrer nofollow' + .form-text.text-muted + = _("See GitLab's %{password_policy_guidelines}").html_safe % { password_policy_guidelines: password_policy_guidelines_link } + .form-group = f.label :domain_whitelist, 'Whitelisted domains for sign-ups', class: 'label-bold' = f.text_area :domain_whitelist_raw, placeholder: 'domain.com', class: 'form-control', rows: 8 .form-text.text-muted ONLY users with e-mail addresses that match these domain(s) will be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml index be5f1f4f9a8..ae90ffd9efc 100644 --- a/app/views/admin/application_settings/_visibility_and_access.html.haml +++ b/app/views/admin/application_settings/_visibility_and_access.html.haml @@ -9,6 +9,7 @@ = f.label s_('ProjectCreationLevel|Default project creation protection'), class: 'label-bold' = f.select :default_project_creation, options_for_select(Gitlab::Access.project_creation_options, @application_setting.default_project_creation), {}, class: 'form-control' = render_if_exists 'admin/application_settings/default_project_deletion_protection_setting', form: f + = render_if_exists 'admin/application_settings/default_project_deletion_adjourned_period_setting', form: f .form-group.visibility-level-setting = f.label :default_project_visibility, class: 'label-bold' = render('shared/visibility_radios', model_method: :default_project_visibility, form: f, selected_level: @application_setting.default_project_visibility, form_model: Project.new) @@ -53,6 +54,7 @@ = select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control') %span.form-text.text-muted#clone-protocol-help = _('Allow only the selected protocols to be used for Git access.') + .form-group = f.label :custom_http_clone_url_root, _('Custom Git clone URL for HTTP(S)'), class: 'label-bold' = f.text_field :custom_http_clone_url_root, class: 'form-control', placeholder: 'https://git.example.com', :'aria-describedby' => 'custom_http_clone_url_root_help_block' diff --git a/app/views/admin/application_settings/integrations.html.haml b/app/views/admin/application_settings/integrations.html.haml index 0aa833e49a8..c6318c9bb2f 100644 --- a/app/views/admin/application_settings/integrations.html.haml +++ b/app/views/admin/application_settings/integrations.html.haml @@ -8,5 +8,5 @@ = render_if_exists 'admin/application_settings/slack' = render 'admin/application_settings/third_party_offers' = render 'admin/application_settings/snowplow' -= render 'admin/application_settings/eks' if Feature.enabled?(:create_eks_clusters) += render 'admin/application_settings/eks' diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml index 962234d3aea..44d57beec0f 100644 --- a/app/views/admin/broadcast_messages/_form.html.haml +++ b/app/views/admin/broadcast_messages/_form.html.haml @@ -1,5 +1,5 @@ .broadcast-message-preview{ style: broadcast_message_style(@broadcast_message) } - = icon('bullhorn') + = sprite_icon('bullhorn', size: 16, css_class:'vertical-align-text-top mr-2') .js-broadcast-message-preview - if @broadcast_message.message.present? = render_broadcast_message(@broadcast_message) @@ -40,6 +40,13 @@ = f.color_field :font, class: "form-control text-font-color" .form-group.row .col-sm-2.col-form-label + = f.label :target_path, _('Target Path') + .col-sm-10 + = f.text_field :target_path, class: "form-control" + .form-text.text-muted + = _('Paths can contain wildcards, like */welcome') + .form-group.row + .col-sm-2.col-form-label = f.label :starts_at, _("Starts at (UTC)") .col-sm-10.datetime-controls = f.datetime_select :starts_at, {}, class: 'form-control form-control-inline' diff --git a/app/views/admin/broadcast_messages/index.html.haml b/app/views/admin/broadcast_messages/index.html.haml index eb4dfdf2858..4731421fd9e 100644 --- a/app/views/admin/broadcast_messages/index.html.haml +++ b/app/views/admin/broadcast_messages/index.html.haml @@ -19,6 +19,7 @@ %th Preview %th Starts %th Ends + %th Target Path %th %tbody - @broadcast_messages.each do |message| @@ -32,6 +33,8 @@ %td = message.ends_at %td + = message.target_path + %td = link_to sprite_icon('pencil-square'), edit_admin_broadcast_message_path(message), title: 'Edit', class: 'btn' = link_to sprite_icon('remove'), admin_broadcast_message_path(message), method: :delete, remote: true, title: 'Remove', class: 'js-remove-tr btn btn-danger' diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index e5a3c0df9bf..6b138445675 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -46,7 +46,8 @@ enabled: allow_signup?) = feature_entry(_('LDAP'), - enabled: Gitlab.config.ldap.enabled) + enabled: Gitlab.config.ldap.enabled, + doc_href: help_page_path('administration/auth/ldap')) = feature_entry(_('Gravatar'), href: admin_application_settings_path(anchor: 'js-account-settings'), @@ -54,10 +55,12 @@ = feature_entry(_('OmniAuth'), href: admin_application_settings_path(anchor: 'js-signin-settings'), - enabled: Gitlab::Auth.omniauth_enabled?) + enabled: Gitlab::Auth.omniauth_enabled?, + doc_href: help_page_path('integration/omniauth')) = feature_entry(_('Reply by email'), - enabled: Gitlab::IncomingEmail.enabled?) + enabled: Gitlab::IncomingEmail.enabled?, + doc_href: help_page_path('administration/reply_by_email')) = render_if_exists 'admin/dashboard/elastic_and_geo' diff --git a/app/views/admin/groups/_group.html.haml b/app/views/admin/groups/_group.html.haml index 395c469255e..3444e423235 100644 --- a/app/views/admin/groups/_group.html.haml +++ b/app/views/admin/groups/_group.html.haml @@ -1,7 +1,7 @@ - group = local_assigns.fetch(:group) - css_class = 'no-description' if group.description.blank? -%li.group-row{ class: css_class } +%li.group-row.py-3{ class: css_class } .controls = link_to _('Edit'), admin_group_edit_path(group), id: "edit_#{dom_id(group)}", class: 'btn' = link_to _('Delete'), [:admin, group], data: { confirm: _("Are you sure you want to remove %{group_name}?") % { group_name: group.name } }, method: :delete, class: 'btn btn-remove' diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index f9cc118a252..160c3b4d06d 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -19,7 +19,8 @@ = group_icon(@group, class: "avatar s60") %li %span.light= _('Name:') - %strong= @group.name + %strong + = link_to @group.name, group_path(@group) %li %span.light= _('Path:') %strong diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml index 9c6c74ed965..9ce0fa8d401 100644 --- a/app/views/admin/hooks/edit.html.haml +++ b/app/views/admin/hooks/edit.html.haml @@ -1,21 +1,18 @@ -- add_to_breadcrumbs "System Hooks", admin_hooks_path -- page_title 'Edit System Hook' -%h3.page-title - Edit System Hook +- add_to_breadcrumbs @hook.pluralized_name, admin_hooks_path +- page_title _('Edit System Hook') -%p.light - #{link_to 'System hooks ', help_page_path('system_hooks/system_hooks')} can be - used for binding events when GitLab creates a User or Project. +.row.prepend-top-default + .col-lg-3 + = render 'shared/web_hooks/title_and_docs', hook: @hook -%hr + .col-lg-9.append-bottom-default + = form_for @hook, as: :hook, url: admin_hook_path do |f| + = render partial: 'form', locals: { form: f, hook: @hook } + .form-actions + %span>= f.submit _('Save changes'), class: 'btn btn-success append-right-8' + = render 'shared/web_hooks/test_button', hook: @hook + = link_to _('Delete'), admin_hook_path(@hook), method: :delete, class: 'btn btn-remove float-right', data: { confirm: _('Are you sure?') } -= form_for @hook, as: :hook, url: admin_hook_path do |f| - = render partial: 'form', locals: { form: f, hook: @hook } - .form-actions - = f.submit 'Save changes', class: 'btn btn-success' - = render 'shared/web_hooks/test_button', triggers: SystemHook.triggers, hook: @hook - = link_to 'Remove', admin_hook_path(@hook), method: :delete, class: 'btn btn-remove float-right', data: { confirm: 'Are you sure?' } + %hr -%hr - -= render partial: 'admin/hook_logs/index', locals: { hook: @hook, hook_logs: @hook_logs } + = render partial: 'admin/hook_logs/index', locals: { hook: @hook, hook_logs: @hook_logs } diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml index b65bf07160a..eed3ec74d60 100644 --- a/app/views/admin/hooks/index.html.haml +++ b/app/views/admin/hooks/index.html.haml @@ -1,35 +1,14 @@ -- page_title 'System Hooks' +- page_title @hook.pluralized_name + .row.prepend-top-default .col-lg-4 - %h4.prepend-top-0 - = page_title - %p - #{link_to 'System hooks ', help_page_path('system_hooks/system_hooks')} can be - used for binding events when GitLab creates a User or Project. + = render 'shared/web_hooks/title_and_docs', hook: @hook .col-lg-8.append-bottom-default = form_for @hook, as: :hook, url: admin_hooks_path do |f| = render partial: 'form', locals: { form: f, hook: @hook } - = f.submit 'Add system hook', class: 'btn btn-success' - - %hr + = f.submit _('Add system hook'), class: 'btn btn-success' - - if @hooks.any? - .card - .card-header - System hooks (#{@hooks.count}) - %ul.content-list - - @hooks.each do |hook| - %li - .controls - = render 'shared/web_hooks/test_button', triggers: SystemHook.triggers, hook: hook, button_class: 'btn-sm' - = link_to 'Edit', edit_admin_hook_path(hook), class: 'btn btn-sm' - = link_to 'Remove', admin_hook_path(hook), data: { confirm: 'Are you sure?' }, method: :delete, class: 'btn btn-remove btn-sm' - .monospace= hook.url - %div - - SystemHook.triggers.each_value do |event| - - if hook.public_send(event) - %span.badge.badge-gray= event.to_s.titleize - %span.badge.badge-gray SSL Verification: #{hook.enable_ssl_verification ? 'enabled' : 'disabled'} + = render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class = render 'shared/plugins/index' diff --git a/app/views/admin/projects/_archived.html.haml b/app/views/admin/projects/_archived.html.haml new file mode 100644 index 00000000000..8b4d5806c47 --- /dev/null +++ b/app/views/admin/projects/_archived.html.haml @@ -0,0 +1,3 @@ +- if project.archived + %span.badge.badge-warning + = _('archived') diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml index 2f7ad35eb3e..f842ab2d009 100644 --- a/app/views/admin/projects/_projects.html.haml +++ b/app/views/admin/projects/_projects.html.haml @@ -14,8 +14,7 @@ .stats %span.badge.badge-pill = storage_counter(project.statistics&.storage_size) - - if project.archived - %span.badge.badge-warning archived + = render_if_exists 'admin/projects/archived', project: project .title = link_to(admin_project_path(project)) do .dash-project-avatar diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index 2bf2b5fce8d..f8ef7a45f7f 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -10,7 +10,7 @@ %br %div - %span= _('Each Runner can be in one of the following states:') + %span= _('Each Runner can be in one of the following states and/or belong to one of the following types:') %ul %li %span.badge.badge-success shared @@ -120,7 +120,7 @@ .runners-content.content-list .table-holder .gl-responsive-table-row.table-row-header{ role: 'row' } - .table-section.section-10{ role: 'rowheader' }= _('Type') + .table-section.section-10{ role: 'rowheader' }= _('Type/State') .table-section.section-10{ role: 'rowheader' }= _('Runner token') .table-section.section-20{ role: 'rowheader' }= _('Description') .table-section.section-10{ role: 'rowheader' }= _('Version') diff --git a/app/views/admin/sessions/_new_base.html.haml b/app/views/admin/sessions/_new_base.html.haml index 3d77a439d61..50fa48855c0 100644 --- a/app/views/admin/sessions/_new_base.html.haml +++ b/app/views/admin/sessions/_new_base.html.haml @@ -4,4 +4,4 @@ = password_field_tag :password, nil, class: 'form-control', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' } .submit-container.move-submit-down - = submit_tag _('Enter Admin Mode'), class: 'btn btn-success', data: { qa_selector: 'sign_in_button' } + = submit_tag _('Enter Admin Mode'), class: 'btn btn-success', data: { qa_selector: 'enter_admin_mode_button' } diff --git a/app/views/admin/sessions/_signin_box.html.haml b/app/views/admin/sessions/_signin_box.html.haml deleted file mode 100644 index 1d19915d3c5..00000000000 --- a/app/views/admin/sessions/_signin_box.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -- if any_form_based_providers_enabled? - - - if password_authentication_enabled_for_web? - .login-box.tab-pane{ id: 'login-pane', role: 'tabpanel' } - .login-body - = render 'admin/sessions/new_base' - -- elsif password_authentication_enabled_for_web? - .login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' } - .login-body - = render 'admin/sessions/new_base' diff --git a/app/views/admin/sessions/new.html.haml b/app/views/admin/sessions/new.html.haml index 73028e78ea5..a1d440f2cfd 100644 --- a/app/views/admin/sessions/new.html.haml +++ b/app/views/admin/sessions/new.html.haml @@ -7,9 +7,16 @@ #signin-container = render 'admin/sessions/tabs_normal' .tab-content - - if password_authentication_enabled_for_web? - = render 'admin/sessions/signin_box' - - else - -# Show a message if none of the mechanisms above are enabled + - if !current_user.require_password_creation_for_web? + .login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' } + .login-body + = render 'admin/sessions/new_base' + + - if omniauth_enabled? && button_based_providers_enabled? + .clearfix + = render 'devise/shared/omniauth_box' + + -# Show a message if none of the mechanisms above are enabled + - if current_user.require_password_creation_for_web? && !omniauth_enabled? .prepend-top-default.center = _('No authentication methods configured.') diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml index bb1e22cc610..e3ab2e4f9bd 100644 --- a/app/views/admin/users/_access_levels.html.haml +++ b/app/views/admin/users/_access_levels.html.haml @@ -43,7 +43,7 @@ = f.check_box :external do External %p.light - External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects or groups. + External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects, groups, or personal snippets. %row.hidden#warning_external_automatically_set.hidden .badge.badge-warning.text-white = _('Automatically marked as default internal user') diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml index 60ca7e4e267..793ddef2c58 100644 --- a/app/views/award_emoji/_awards_block.html.haml +++ b/app/views/award_emoji/_awards_block.html.haml @@ -17,3 +17,4 @@ %span{ class: "award-control-icon award-control-icon-positive" }= sprite_icon('smiley') %span{ class: "award-control-icon award-control-icon-super-positive" }= sprite_icon('smile') = icon('spinner spin', class: "award-control-icon award-control-icon-loading") + = yield diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml index ed9b3ab1940..4244556a24a 100644 --- a/app/views/ci/variables/_variable_row.html.haml +++ b/app/views/ci/variables/_variable_row.html.haml @@ -44,31 +44,21 @@ .ci-variable-body-item.ci-variable-protected-item.table-section.section-20.mr-0.border-top-0 .append-right-default = s_("CiVariable|Protected") - %button{ type: 'button', - class: "js-project-feature-toggle project-feature-toggle #{'is-checked' if is_protected}", - "aria-label": s_("CiVariable|Toggle protected") } + = render "shared/buttons/project_feature_toggle", is_checked: is_protected, label: s_("CiVariable|Toggle protected") do %input{ type: "hidden", class: 'js-ci-variable-input-protected js-project-feature-toggle-input', name: protected_input_name, value: is_protected, data: { default: is_protected_default.to_s } } - %span.toggle-icon - = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked') - = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked') .ci-variable-body-item.ci-variable-masked-item.table-section.section-20.mr-0.border-top-0 .append-right-default = s_("CiVariable|Masked") - %button{ type: 'button', - class: "js-project-feature-toggle project-feature-toggle qa-variable-masked #{'is-checked' if is_masked}", - "aria-label": s_("CiVariable|Toggle masked") } + = render "shared/buttons/project_feature_toggle", is_checked: is_masked, label: s_("CiVariable|Toggle masked"), class_list: "js-project-feature-toggle project-feature-toggle qa-variable-masked" do %input{ type: "hidden", class: 'js-ci-variable-input-masked js-project-feature-toggle-input', name: masked_input_name, value: is_masked, data: { default: is_masked_default.to_s } } - %span.toggle-icon - = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked') - = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked') = render_if_exists 'ci/variables/environment_scope', form_field: form_field, variable: variable %button.js-row-remove-button.ci-variable-row-remove-button.table-section.section-5.border-top-0{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') } = icon('minus-circle') diff --git a/app/views/clusters/clusters/_advanced_settings.html.haml b/app/views/clusters/clusters/_advanced_settings.html.haml index 493d7a00854..77f7c478ffa 100644 --- a/app/views/clusters/clusters/_advanced_settings.html.haml +++ b/app/views/clusters/clusters/_advanced_settings.html.haml @@ -8,10 +8,10 @@ - unless @cluster.provided_by_user? .append-bottom-20 %label.append-bottom-10 - = s_('ClusterIntegration|Google Kubernetes Engine') + = @cluster.provider_label %p - - link_gke = link_to(s_('ClusterIntegration|Google Kubernetes Engine'), @cluster.gke_cluster_url, target: '_blank', rel: 'noopener noreferrer') - = s_('ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}').html_safe % { link_gke: link_gke } + - provider_link = link_to(@cluster.provider_label, @cluster.provider_management_url, target: '_blank', rel: 'noopener noreferrer') + = s_('ClusterIntegration|Manage your Kubernetes cluster by visiting %{provider_link}').html_safe % { provider_link: provider_link } = form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster, html: { class: 'cluster_management_form' } do |field| @@ -28,9 +28,18 @@ .form-group = field.submit _('Save changes'), class: 'btn btn-success qa-save-domain' + - if @cluster.managed? + .sub-section.form-group + %h4 + = s_('ClusterIntegration|Clear cluster cache') + %p + = s_("ClusterIntegration|Clear the local cache of namespace and service accounts. This is necessary if your integration has become out of sync. The cache is repopulated during the next CI job that requires namespace and service accounts.") + = link_to(s_('ClusterIntegration|Clear cluster cache'), clusterable.clear_cluster_cache_path(@cluster), method: :delete, class: 'btn btn-primary') + .sub-section.form-group %h4.text-danger = s_('ClusterIntegration|Remove Kubernetes cluster integration') %p = s_("ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster.") - = link_to(s_('ClusterIntegration|Remove integration'), clusterable.cluster_path(@cluster), method: :delete, class: 'btn btn-danger', data: { confirm: s_("ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster.")}) + + #js-cluster-remove-actions{ data: { cluster_path: clusterable.cluster_path(@cluster), cluster_name: @cluster.name } } diff --git a/app/views/clusters/clusters/_banner.html.haml b/app/views/clusters/clusters/_banner.html.haml index 7d97aaccbcf..82057fd0463 100644 --- a/app/views/clusters/clusters/_banner.html.haml +++ b/app/views/clusters/clusters/_banner.html.haml @@ -6,17 +6,19 @@ %span.spinner.spinner-dark.spinner-sm{ 'aria-label': 'Loading' } %span.prepend-left-4= s_('ClusterIntegration|Kubernetes cluster is being created...') -.hidden.row.js-cluster-api-unreachable.bs-callout.bs-callout-warning{ role: 'alert' } - .col-11 +.hidden.row.js-cluster-api-unreachable.gl-alert.gl-alert-warning{ role: 'alert' } + = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + %button.js-close-banner.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } + = sprite_icon('close', size: 16, css_class: 'gl-icon') + .gl-alert-body = s_('ClusterIntegration|Your cluster API is unreachable. Please ensure your API URL is correct.') - .col-1.p-0 - %button.js-close-banner.close.cluster-application-banner-close.h-100.m-0= "×" -.hidden.js-cluster-authentication-failure.row.js-cluster-api-unreachable.bs-callout.bs-callout-warning{ role: 'alert' } - .col-11 +.hidden.js-cluster-authentication-failure.js-cluster-api-unreachable.gl-alert.gl-alert-warning{ role: 'alert' } + = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + %button.js-close-banner.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } + = sprite_icon('close', size: 16, css_class: 'gl-icon') + .gl-alert-body = s_('ClusterIntegration|There was a problem authenticating with your cluster. Please ensure your CA Certificate and Token are valid.') - .col-1.p-0 - %button.js-close-banner.close.cluster-application-banner-close.h-100.m-0= "×" .hidden.js-cluster-success.bs-callout.bs-callout-success{ role: 'alert' } = s_("ClusterIntegration|Kubernetes cluster was successfully created.") diff --git a/app/views/clusters/clusters/_form.html.haml b/app/views/clusters/clusters/_form.html.haml index 3d0266a2d5b..f9085b781fb 100644 --- a/app/views/clusters/clusters/_form.html.haml +++ b/app/views/clusters/clusters/_form.html.haml @@ -3,14 +3,8 @@ .form-group %h5= s_('ClusterIntegration|Integration status') %label.append-bottom-0.js-cluster-enable-toggle-area - %button{ type: 'button', - class: "js-project-feature-toggle project-feature-toggle #{'is-checked' if @cluster.enabled?} #{'is-disabled' unless can?(current_user, :update_cluster, @cluster)}", - "aria-label": s_("ClusterIntegration|Toggle Kubernetes cluster"), - disabled: !can?(current_user, :update_cluster, @cluster) } + = render "shared/buttons/project_feature_toggle", is_checked: @cluster.enabled?, label: s_("ClusterIntegration|Toggle Kubernetes cluster"), disabled: !can?(current_user, :update_cluster, @cluster) do = field.hidden_field :enabled, { class: 'js-project-feature-toggle-input'} - %span.toggle-icon - = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked') - = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked') .form-text.text-muted= s_('ClusterIntegration|Enable or disable GitLab\'s connection to your Kubernetes cluster.') .form-group diff --git a/app/views/clusters/clusters/_namespace.html.haml b/app/views/clusters/clusters/_namespace.html.haml index 0c64819ad62..8a86fd90963 100644 --- a/app/views/clusters/clusters/_namespace.html.haml +++ b/app/views/clusters/clusters/_namespace.html.haml @@ -1,4 +1,4 @@ -- managed_namespace_help_text = s_('ClusterIntegration|Choose a prefix to be used for your namespaces. Defaults to your project path.') +- managed_namespace_help_text = s_('ClusterIntegration|Set a prefix for your namespaces. If not set, defaults to your project path. If modified, existing environments will use their current namespaces until the cluster cache is cleared.') - non_managed_namespace_help_text = s_('ClusterIntegration|The namespace associated with your project. This will be used for deploy boards, pod logs, and Web terminals.') - managed_namespace_help_link = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank' diff --git a/app/views/clusters/clusters/aws/_new.html.haml b/app/views/clusters/clusters/aws/_new.html.haml index 795b80bfb6f..d89e6965dac 100644 --- a/app/views/clusters/clusters/aws/_new.html.haml +++ b/app/views/clusters/clusters/aws/_new.html.haml @@ -5,19 +5,12 @@ - else .js-create-eks-cluster-form-container{ data: { 'gitlab-managed-cluster-help-path' => help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), 'create-role-path' => clusterable.authorize_aws_role_path, - 'sign-out-path' => clusterable.revoke_aws_role_path, 'create-cluster-path' => clusterable.create_aws_clusters_path, - 'get-roles-path' => clusterable.aws_api_proxy_path('roles'), - 'get-regions-path' => clusterable.aws_api_proxy_path('regions'), - 'get-key-pairs-path' => clusterable.aws_api_proxy_path('key_pairs'), - 'get-vpcs-path' => clusterable.aws_api_proxy_path('vpcs'), - 'get-subnets-path' => clusterable.aws_api_proxy_path('subnets'), - 'get-security-groups-path' => clusterable.aws_api_proxy_path('security_groups'), - 'get-instance-types-path' => clusterable.aws_api_proxy_path('instance_types'), 'account-id' => Gitlab::CurrentSettings.eks_account_id, 'external-id' => @aws_role.role_external_id, + 'role-arn' => @aws_role.role_arn, + 'instance-types' => @instance_types, 'kubernetes-integration-help-path' => help_page_path('user/project/clusters/index'), 'account-and-external-ids-help-path' => help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'eks-cluster'), 'create-role-arn-help-path' => help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'eks-cluster'), - 'external-link-icon' => icon('external-link'), - 'has-credentials' => @aws_role.role_arn.present?.to_s } } + 'external-link-icon' => icon('external-link') } } diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml index 95670a2ec87..ab01569b8fd 100644 --- a/app/views/clusters/clusters/gcp/_form.html.haml +++ b/app/views/clusters/clusters/gcp/_form.html.haml @@ -64,7 +64,7 @@ %p.form-text.text-muted = s_('ClusterIntegration|Learn more about %{help_link_start_machine_type}machine types%{help_link_end} and %{help_link_start_pricing}pricing%{help_link_end}.').html_safe % { help_link_start_machine_type: help_link_start % { url: machine_type_link_url }, help_link_start_pricing: help_link_start % { url: pricing_link_url }, help_link_end: help_link_end } - - if Feature.enabled?(:create_cloud_run_clusters, clusterable) + - if Feature.enabled?(:create_cloud_run_clusters, clusterable, default_enabled: true) .form-group = provider_gcp_field.check_box :cloud_run, { label: s_('ClusterIntegration|Enable Cloud Run on GKE (beta)'), label_class: 'label-bold' } diff --git a/app/views/clusters/clusters/gcp/_gcp_not_configured.html.haml b/app/views/clusters/clusters/gcp/_gcp_not_configured.html.haml index b57e45e9812..f1f26a0aab8 100644 --- a/app/views/clusters/clusters/gcp/_gcp_not_configured.html.haml +++ b/app/views/clusters/clusters/gcp/_gcp_not_configured.html.haml @@ -1,3 +1,3 @@ - documentation_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path("integration/google") } - link_end = '<a/>'.html_safe -= s_('Google authentication is not %{link_start}property configured%{link_end}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_start: documentation_link_start, link_end: link_end } += s_('Google authentication is not %{link_start}properly configured%{link_end}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_start: documentation_link_start, link_end: link_end } diff --git a/app/views/clusters/clusters/gcp/_new.html.haml b/app/views/clusters/clusters/gcp/_new.html.haml index 3d47f4bf2c3..6c3a230fb93 100644 --- a/app/views/clusters/clusters/gcp/_new.html.haml +++ b/app/views/clusters/clusters/gcp/_new.html.haml @@ -1,7 +1,5 @@ = render 'clusters/clusters/gcp/header' - if @valid_gcp_token = render 'clusters/clusters/gcp/form' -- elsif @authorize_url - = render 'clusters/clusters/gcp/signin_with_google_button' - else = render 'clusters/clusters/gcp/gcp_not_configured' diff --git a/app/views/clusters/clusters/gcp/_signin_with_google_button.html.haml b/app/views/clusters/clusters/gcp/_signin_with_google_button.html.haml deleted file mode 100644 index 65cfa6552b1..00000000000 --- a/app/views/clusters/clusters/gcp/_signin_with_google_button.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -.signin-with-google - - create_account_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: 'https://accounts.google.com/SignUpWithoutGmail?service=cloudconsole&continue=https%3A%2F%2Fconsole.cloud.google.com%2Ffreetrial%3Futm_campaign%3D2018_cpanel%26utm_source%3Dgitlab%26utm_medium%3Dreferral' } - = link_to(image_tag('auth_buttons/signin_with_google.png', width: '191px', alt: _('Sign in with Google')), @authorize_url) - = s_('or %{link_start}create a new Google account%{link_end}').html_safe % { link_start: create_account_link, link_end: '</a>'.html_safe } diff --git a/app/views/clusters/clusters/new.html.haml b/app/views/clusters/clusters/new.html.haml index cb8cbe4e6f2..629585d82cd 100644 --- a/app/views/clusters/clusters/new.html.haml +++ b/app/views/clusters/clusters/new.html.haml @@ -1,6 +1,5 @@ - breadcrumb_title _('Kubernetes') - page_title _('Kubernetes Cluster') -- create_eks_enabled = Feature.enabled?(:create_eks_clusters) - active_tab = local_assigns.fetch(:active_tab, 'create') = javascript_include_tag 'https://apis.google.com/js/api.js' @@ -14,21 +13,14 @@ %li.nav-item{ role: 'presentation' } %a.nav-link{ href: '#create-cluster-pane', id: 'create-cluster-tab', class: active_when(active_tab == 'create'), data: { toggle: 'tab' }, role: 'tab' } %span - - if create_eks_enabled - = create_new_cluster_label(provider: params[:provider]) - - else - = create_new_cluster_label(provider: 'gcp') + = create_new_cluster_label(provider: params[:provider]) %li.nav-item{ role: 'presentation' } %a.nav-link{ href: '#add-cluster-pane', id: 'add-cluster-tab', class: active_when(active_tab == 'add'), data: { toggle: 'tab' }, role: 'tab' } %span Add existing cluster .tab-content.gitlab-tab-content - - if create_eks_enabled - .tab-pane{ id: 'create-cluster-pane', class: active_when(active_tab == 'create'), role: 'tabpanel' } - = render new_cluster_partial(provider: params[:provider]) - - else - .tab-pane{ id: 'create-cluster-pane', class: active_when(active_tab == 'create'), role: 'tabpanel' } - = render new_cluster_partial(provider: 'gcp') + .tab-pane{ id: 'create-cluster-pane', class: active_when(active_tab == 'create'), role: 'tabpanel' } + = render new_cluster_partial(provider: params[:provider]) .tab-pane{ id: 'add-cluster-pane', class: active_when(active_tab == 'add'), role: 'tabpanel' } = render 'clusters/clusters/user/header' diff --git a/app/views/dashboard/_snippets_head.html.haml b/app/views/dashboard/_snippets_head.html.haml index 34aca40d0d1..4958cdc3745 100644 --- a/app/views/dashboard/_snippets_head.html.haml +++ b/app/views/dashboard/_snippets_head.html.haml @@ -3,7 +3,8 @@ - if current_user && current_user.snippets.any? || @snippets.any? .page-title-controls - = link_to _("New snippet"), new_snippet_path, class: "btn btn-success", title: _("New snippet") + - if can?(current_user, :create_personal_snippet) + = link_to _("New snippet"), new_snippet_path, class: "btn btn-success", title: _("New snippet") .top-area %ul.nav-links.nav.nav-tabs diff --git a/app/views/dashboard/projects/_blank_state_welcome.html.haml b/app/views/dashboard/projects/_blank_state_welcome.html.haml index 913f0e8cfae..003e6f18b33 100644 --- a/app/views/dashboard/projects/_blank_state_welcome.html.haml +++ b/app/views/dashboard/projects/_blank_state_welcome.html.haml @@ -1,5 +1,3 @@ -- public_project_count = ProjectsFinder.new(current_user: current_user).execute.count - .blank-state-row - if current_user.can_create_project? = link_to new_project_path, class: "blank-state blank-state-link" do @@ -30,19 +28,15 @@ %p.blank-state-text Groups are the best way to manage projects and members. - - if public_project_count > 0 - = link_to trending_explore_projects_path, class: "blank-state blank-state-link" do - .blank-state-icon - = custom_icon("globe", size: 50) - .blank-state-body - %h3.blank-state-title - Explore public projects - %p.blank-state-text - There are - = number_with_delimiter(public_project_count) - public projects on this server. - Public projects are an easy way to allow - everyone to have read-only access. + = link_to trending_explore_projects_path, class: "blank-state blank-state-link" do + .blank-state-icon + = custom_icon("globe", size: 50) + .blank-state-body + %h3.blank-state-title + Explore public projects + %p.blank-state-text + Public projects are an easy way to allow + everyone to have read-only access. = link_to "https://docs.gitlab.com/", class: "blank-state blank-state-link" do .blank-state-icon diff --git a/app/views/dashboard/snippets/index.html.haml b/app/views/dashboard/snippets/index.html.haml index 2caa8e0cac4..44a9270971a 100644 --- a/app/views/dashboard/snippets/index.html.haml +++ b/app/views/dashboard/snippets/index.html.haml @@ -1,6 +1,7 @@ - @hide_top_links = true - page_title "Snippets" - header_title "Snippets", dashboard_snippets_path +- button_path = new_snippet_path if can?(current_user, :create_personal_snippet) = render 'dashboard/snippets_head' - if current_user.snippets.exists? @@ -9,4 +10,4 @@ - if current_user.snippets.exists? = render partial: 'shared/snippets/list', locals: { link_project: true } - else - = render 'shared/empty_states/snippets', button_path: new_snippet_path + = render 'shared/empty_states/snippets', button_path: button_path diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index 4c88660ccb9..618cfe57be4 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -23,6 +23,13 @@ %span.d-block= s_('GroupSettings|Disable email notifications') %span.text-muted= s_('GroupSettings|This setting will override user notification preferences for all members of the group, subgroups, and projects.') + .form-group.append-bottom-default + .form-check + = f.check_box :mentions_disabled, checked: @group.mentions_disabled?, class: 'form-check-input' + = f.label :mentions_disabled, class: 'form-check-label' do + %span.d-block= s_('GroupSettings|Disable group mentions') + %span.text-muted= s_('GroupSettings|This setting will prevent group members from being notified if the group is mentioned.') + = render_if_exists 'groups/settings/ip_restriction', f: f, group: @group = render_if_exists 'groups/settings/allowed_email_domain', f: f, group: @group = render 'groups/settings/lfs', f: f diff --git a/app/views/import/gitlab_projects/new.html.haml b/app/views/import/gitlab_projects/new.html.haml index a19c8911559..feebbccf46a 100644 --- a/app/views/import/gitlab_projects/new.html.haml +++ b/app/views/import/gitlab_projects/new.html.haml @@ -14,7 +14,6 @@ = _("To move or copy an entire GitLab project from another GitLab installation to this one, navigate to the original project's settings page, generate an export file, and upload it here.") .row .form-group.col-sm-12 - = hidden_field_tag :namespace_id, @namespace.id = label_tag :file, _('GitLab project export'), class: 'label-bold' .form-group = file_field_tag :file, class: '' diff --git a/app/views/instance_statistics/cohorts/_cohorts_table.html.haml b/app/views/instance_statistics/cohorts/_cohorts_table.html.haml index 6a7c999bff3..d4defd3f849 100644 --- a/app/views/instance_statistics/cohorts/_cohorts_table.html.haml +++ b/app/views/instance_statistics/cohorts/_cohorts_table.html.haml @@ -1,25 +1,32 @@ +- number_of_data_columns = @cohorts[:months_included] - 1 .bs-callout.clearfix %p - User cohorts are shown for the last #{@cohorts[:months_included]} - months. Only users with activity are counted in the cohort total; inactive - users are counted separately. + = s_("Cohorts|User cohorts are shown for the last %{months_included} months. Only users with activity are counted in the 'New users' column; inactive users are counted separately.") % { months_included: @cohorts[:months_included] } = link_to icon('question-circle'), help_page_path('user/instance_statistics/user_cohorts', anchor: 'cohorts'), title: 'About this feature', target: '_blank' -.table-holder +.table-holder.d-xl-table %table.table %thead %tr - %th Registration month - %th Inactive users - %th Cohort total - - @cohorts[:months_included].times do |i| - %th Month #{i} + %th.border-right.pt-4{ colspan: 3 } + %th.font-weight-bold.pt-4{ colspan: number_of_data_columns } + = s_("Cohorts|Returning users") + %tr + %th.border-top-0 + = s_("Cohorts|Registration month") + %th.border-top-0 + = s_("Cohorts|Inactive users") + %th.border-top-0.border-right + = s_("Cohorts|New users") + - number_of_data_columns.times do |i| + %th.border-top-0 + = s_("Cohorts|Month %{month_index}") % { month_index: i + 1 } %tbody - @cohorts[:cohorts].each do |cohort| %tr %td= cohort[:registration_month] %td= cohort[:inactive] - %td= cohort[:total] + %td.border-right= cohort[:total] - cohort[:activity_months].each do |activity_month| %td - next if cohort[:total] == '0' diff --git a/app/views/instance_statistics/conversational_development_index/_callout.html.haml b/app/views/instance_statistics/conversational_development_index/_callout.html.haml deleted file mode 100644 index a4256e23979..00000000000 --- a/app/views/instance_statistics/conversational_development_index/_callout.html.haml +++ /dev/null @@ -1,13 +0,0 @@ -.prepend-top-default -.user-callout{ data: { uid: 'convdev_intro_callout_dismissed' } } - .bordered-box.landing.content-block - %button.btn.btn-default.close.js-close-callout{ type: 'button', - 'aria-label' => _('Dismiss ConvDev introduction') } - = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true') - .user-callout-copy - %h4 - = _('Introducing Your Conversational Development Index') - %p - = _('Your Conversational Development Index gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers.') - .svg-container.convdev - = custom_icon('convdev_overview') diff --git a/app/views/instance_statistics/dev_ops_score/_callout.html.haml b/app/views/instance_statistics/dev_ops_score/_callout.html.haml new file mode 100644 index 00000000000..64eb72c0d8d --- /dev/null +++ b/app/views/instance_statistics/dev_ops_score/_callout.html.haml @@ -0,0 +1,13 @@ +.prepend-top-default +.user-callout{ data: { uid: 'dev_ops_score_intro_callout_dismissed' } } + .bordered-box.landing.content-block + %button.btn.btn-default.close.js-close-callout{ type: 'button', + 'aria-label' => _('Dismiss DevOps Score introduction') } + = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true') + .user-callout-copy + %h4 + = _('Introducing Your DevOps Score') + %p + = _('Your DevOps Score gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers.') + .svg-container.devops + = custom_icon('dev_ops_score_overview') diff --git a/app/views/instance_statistics/conversational_development_index/_card.html.haml b/app/views/instance_statistics/dev_ops_score/_card.html.haml index 76af55dcf7a..c63bd96a175 100644 --- a/app/views/instance_statistics/conversational_development_index/_card.html.haml +++ b/app/views/instance_statistics/dev_ops_score/_card.html.haml @@ -1,6 +1,6 @@ -.convdev-card-wrapper - .convdev-card{ class: "convdev-card-#{score_level(card.percentage_score)}" } - .convdev-card-title +.devops-card-wrapper + .devops-card{ class: "devops-card-#{score_level(card.percentage_score)}" } + .devops-card-title %h3 = card.title .light-text diff --git a/app/views/instance_statistics/conversational_development_index/_disabled.html.haml b/app/views/instance_statistics/dev_ops_score/_disabled.html.haml index b854e15d36f..da27ea17b61 100644 --- a/app/views/instance_statistics/conversational_development_index/_disabled.html.haml +++ b/app/views/instance_statistics/dev_ops_score/_disabled.html.haml @@ -1,6 +1,6 @@ -.container.convdev-empty +.container.devops-empty .col-sm-12.justify-content-center.text-center - = custom_icon('convdev_no_index') + = custom_icon('dev_ops_score_no_index') %h4= _('Usage ping is not enabled') - if !current_user.admin? %p diff --git a/app/views/instance_statistics/conversational_development_index/_no_data.html.haml b/app/views/instance_statistics/dev_ops_score/_no_data.html.haml index 4e8f34cd574..54598244039 100644 --- a/app/views/instance_statistics/conversational_development_index/_no_data.html.haml +++ b/app/views/instance_statistics/dev_ops_score/_no_data.html.haml @@ -1,7 +1,7 @@ -.container.convdev-empty +.container.devops-empty .col-sm-12.justify-content-center.text-center - = custom_icon('convdev_no_data') + = custom_icon('dev_ops_score_no_data') %h4= _('Data is still calculating...') %p = _('In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index.') - = link_to _('Learn more'), help_page_path('user/instance_statistics/convdev'), target: '_blank' + = link_to _('Learn more'), help_page_path('user/instance_statistics/dev_ops_score'), target: '_blank' diff --git a/app/views/instance_statistics/conversational_development_index/index.html.haml b/app/views/instance_statistics/dev_ops_score/index.html.haml index 49c8fdc9630..44c6e9664db 100644 --- a/app/views/instance_statistics/conversational_development_index/index.html.haml +++ b/app/views/instance_statistics/dev_ops_score/index.html.haml @@ -1,8 +1,8 @@ -- page_title _('ConvDev Index') +- page_title _('DevOps Score') - usage_ping_enabled = Gitlab::CurrentSettings.usage_ping_enabled .container - - if usage_ping_enabled && show_callout?('convdev_intro_callout_dismissed') + - if usage_ping_enabled && show_callout?('dev_ops_score_intro_callout_dismissed') = render 'callout' .prepend-top-default @@ -11,23 +11,23 @@ - elsif @metric.blank? = render 'no_data' - else - .convdev - .convdev-header - %h2.convdev-header-title{ class: "convdev-#{score_level(@metric.average_percentage_score)}-score" } + .devops + .devops-header + %h2.devops-header-title{ class: "devops-#{score_level(@metric.average_percentage_score)}-score" } = number_to_percentage(@metric.average_percentage_score, precision: 1) - .convdev-header-subtitle + .devops-header-subtitle = _('index') %br = _('score') - = link_to icon('question-circle', 'aria-hidden' => 'true'), help_page_path('user/instance_statistics/convdev') + = link_to icon('question-circle', 'aria-hidden' => 'true'), help_page_path('user/instance_statistics/dev_ops_score') - .convdev-cards.board-card-container + .devops-cards.board-card-container - @metric.cards.each do |card| = render 'card', card: card - .convdev-steps.d-none.d-lg-block.d-xl-block + .devops-steps.d-none.d-lg-block.d-xl-block - @metric.idea_to_production_steps.each_with_index do |step, index| - .convdev-step{ class: "convdev-#{score_level(step.percentage_score)}-score" } + .devops-step{ class: "devops-#{score_level(step.percentage_score)}-score" } = custom_icon("i2p_step_#{index + 1}") - %h4.convdev-step-title + %h4.devops-step-title = step.title diff --git a/app/views/layouts/_broadcast.html.haml b/app/views/layouts/_broadcast.html.haml index e2dbdcbb939..ee3ca824342 100644 --- a/app/views/layouts/_broadcast.html.haml +++ b/app/views/layouts/_broadcast.html.haml @@ -1,2 +1,2 @@ -- BroadcastMessage.current&.each do |message| +- current_broadcast_messages&.each do |message| = broadcast_message(message) diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml index a0b030fa3b2..de1caeaa50f 100644 --- a/app/views/layouts/_flash.html.haml +++ b/app/views/layouts/_flash.html.haml @@ -1,8 +1,9 @@ +-# We currently only support `alert`, `notice`, `success`, 'toast' .flash-container.flash-container-page.sticky - -# We currently only support `alert`, `notice`, `success` - flash.each do |key, value| - -# Don't show a flash message if the message is nil - - if value + - if key == 'toast' && value + .js-toast-message{ data: { message: value } } + - elsif value %div{ class: "flash-#{key} mb-2" } %span= value %div{ class: "close-icon-wrapper js-close-icon" } diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index c38f96f302a..f4ab491a38e 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,7 +1,7 @@ !!! 5 %html{ lang: I18n.locale, class: page_class } = render "layouts/head" - %body{ class: "#{user_application_theme} #{@body_class} #{client_class_list}", data: { page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}", find_file: find_file_path } } + %body{ class: "#{user_application_theme} #{@body_class} #{client_class_list}", data: body_data } = render "layouts/init_auto_complete" if @gfm_form = render "layouts/init_client_detection_flags" = render 'peek/bar' diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index d15f0ae3228..88803f982e8 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -20,7 +20,7 @@ = link_to s_("CurrentUser|Profile"), current_user, class: 'profile-link', data: { user: current_user.username } - if current_user_menu?(:start_trial) %li - %a.profile-link{ href: trials_link_url } + %a.trial-link{ href: trials_link_url } = s_("CurrentUser|Start a Gold trial") = emoji_icon('rocket') - if current_user_menu?(:settings) diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml index e28efb09be5..30109621515 100644 --- a/app/views/layouts/header/_new_dropdown.haml +++ b/app/views/layouts/header/_new_dropdown.haml @@ -38,4 +38,5 @@ %li= link_to _('New project'), new_project_path, class: 'qa-global-new-project-link' - if current_user.can_create_group? %li= link_to _('New group'), new_group_path - %li= link_to _('New snippet'), new_snippet_path, class: 'qa-global-new-snippet-link' + - if current_user.can?(:create_personal_snippet) + %li= link_to _('New snippet'), new_snippet_path, class: 'qa-global-new-snippet-link' diff --git a/app/views/layouts/nav/_breadcrumbs.html.haml b/app/views/layouts/nav/_breadcrumbs.html.haml index f53bd2b5e4d..1b799477093 100644 --- a/app/views/layouts/nav/_breadcrumbs.html.haml +++ b/app/views/layouts/nav/_breadcrumbs.html.haml @@ -2,7 +2,7 @@ - hide_top_links = @hide_top_links || false %nav.breadcrumbs{ role: "navigation", class: [container, @content_class] } - .breadcrumbs-container + .breadcrumbs-container{ class: ("border-bottom-0" if @no_breadcrumb_border && mr_tabs_position_enabled?) } - if defined?(@left_sidebar) = button_tag class: 'toggle-mobile-nav', type: 'button' do %span.sr-only= _("Open sidebar") diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index d339751848b..9a839765286 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -48,7 +48,7 @@ %li.dropdown = render_if_exists 'dashboard/nav_link_list' - if can?(current_user, :read_instance_statistics) - = nav_link(controller: [:conversational_development_index, :cohorts]) do + = nav_link(controller: [:dev_ops_score, :cohorts]) do = link_to instance_statistics_root_path do = _('Instance Statistics') - if current_user.admin? diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index b33ef26f87d..71fef5df5bc 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -182,6 +182,8 @@ %strong.fly-out-top-item-name = _('Deploy Keys') + = render_if_exists 'layouts/nav/sidebar/credentials_link' + = nav_link(controller: :services) do = link_to admin_application_settings_services_path do .nav-icon-container @@ -264,8 +266,8 @@ = link_to network_admin_application_settings_path, title: _('Network'), data: { qa_selector: 'admin_settings_network_item' } do %span = _('Network') - - if template_exists?('admin/application_settings/geo') - = nav_link(path: 'application_settings#geo') do + - if template_exists?('admin/geo/settings/show') + = nav_link do = link_to geo_admin_application_settings_path, title: _('Geo') do %span = _('Geo') diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index a6d2c894185..a027dca1b56 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -44,7 +44,7 @@ - if group_sidebar_link?(:contribution_analytics) = nav_link(path: 'analytics#show') do - = link_to group_analytics_path(@group), title: _('Contribution Analytics'), data: { placement: 'right' } do + = link_to group_analytics_path(@group), title: _('Contribution Analytics'), data: { placement: 'right', qa_selector: 'contribution_analytics_link' } do %span = _('Contribution Analytics') diff --git a/app/views/layouts/nav/sidebar/_instance_statistics.html.haml b/app/views/layouts/nav/sidebar/_instance_statistics.html.haml index 57180f27146..0a84e952442 100644 --- a/app/views/layouts/nav/sidebar/_instance_statistics.html.haml +++ b/app/views/layouts/nav/sidebar/_instance_statistics.html.haml @@ -6,17 +6,17 @@ = sprite_icon('chart', size: 24) .sidebar-context-title= _('Instance Statistics') %ul.sidebar-top-level-items - = nav_link(controller: :conversational_development_index) do - = link_to instance_statistics_conversational_development_index_index_path do + = nav_link(controller: :dev_ops_score) do + = link_to instance_statistics_dev_ops_score_index_path do .nav-icon-container = sprite_icon('comment') %span.nav-item-name - = _('ConvDev Index') + = _('DevOps Score') %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :conversational_development_index, html_options: { class: "fly-out-top-item" } ) do - = link_to instance_statistics_conversational_development_index_index_path do + = nav_link(controller: :dev_ops_score, html_options: { class: "fly-out-top-item" } ) do + = link_to instance_statistics_dev_ops_score_index_path do %strong.fly-out-top-item-name - = _('ConvDev Index') + = _('DevOps Score') - if Gitlab::CurrentSettings.usage_ping_enabled = nav_link(controller: :cohorts) do diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 9b3ad05d0c0..1e2556aecc1 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -144,8 +144,16 @@ %strong.fly-out-top-item-name = issue_tracker.title + - if (project_nav_tab? :labels) && !@project.issues_enabled? + = nav_link(controller: [:labels]) do + = link_to project_labels_path(@project), title: _('Labels'), class: 'shortcuts-labels qa-labels-items' do + .nav-icon-container + = sprite_icon('label') + %span.nav-item-name#js-onboarding-labels-link + = _('Labels') + - if project_nav_tab? :merge_requests - = nav_link(controller: @project.issues_enabled? ? :merge_requests : [:merge_requests, :labels, :milestones]) do + = nav_link(controller: @project.issues_enabled? ? :merge_requests : [:merge_requests, :milestones]) do = link_to project_merge_requests_path(@project), class: 'shortcuts-merge_requests', data: { qa_selector: 'merge_requests_link' } do .nav-icon-container = sprite_icon('git-merge') diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml index de487a94d40..e922b505be8 100644 --- a/app/views/layouts/notify.html.haml +++ b/app/views/layouts/notify.html.haml @@ -20,7 +20,7 @@ #{link_to _("View it on GitLab"), @target_url}. %br -# Don't link the host in the line below, one link in the email is easier to quickly click than two. - = _("You're receiving this email because %{reason}.") % { reason: notification_reason_text(@reason) } + = notification_reason_text(@reason) If you'd like to receive fewer emails, you can - if @labels_url adjust your #{link_to 'label subscriptions', @labels_url}. diff --git a/app/views/layouts/notify.text.erb b/app/views/layouts/notify.text.erb index 0ee30c2a6cf..49ad0b5abc5 100644 --- a/app/views/layouts/notify.text.erb +++ b/app/views/layouts/notify.text.erb @@ -11,7 +11,7 @@ <% end -%> <% end -%> -<%= "You're receiving this email because #{notification_reason_text(@reason)}." %> +<%= notification_reason_text(@reason) %> <%= render_if_exists 'layouts/mailer/additional_text' %> <%= text_footer_message -%> diff --git a/app/views/notify/_note_email.html.haml b/app/views/notify/_note_email.html.haml index dc5529b489b..c558358725c 100644 --- a/app/views/notify/_note_email.html.haml +++ b/app/views/notify/_note_email.html.haml @@ -11,7 +11,7 @@ - if discussion.nil? commented - else - - if discussion.new_discussion? + - if note.start_of_discussion? started a new - else commented on a diff --git a/app/views/notify/_note_email.text.erb b/app/views/notify/_note_email.text.erb index a25daad8458..8e2f7e6f76e 100644 --- a/app/views/notify/_note_email.text.erb +++ b/app/views/notify/_note_email.text.erb @@ -7,7 +7,7 @@ <% if discussion.nil? -%> <%= 'commented' -%>: <% else -%> -<% if discussion.new_discussion? -%> +<% if note.start_of_discussion? -%> <%= 'started a new discussion' -%> <% else -%> <%= 'commented on a discussion' -%> diff --git a/app/views/notify/access_token_about_to_expire_email.html.haml b/app/views/notify/access_token_about_to_expire_email.html.haml new file mode 100644 index 00000000000..d1923e324f7 --- /dev/null +++ b/app/views/notify/access_token_about_to_expire_email.html.haml @@ -0,0 +1,7 @@ +%p + = _('Hi %{username}!') % { username: sanitize_name(@user.name) } +%p + = _('One or more of your personal access tokens will expire in %{days_to_expire} days or less.') % { days_to_expire: @days_to_expire } +%p + - pat_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url } + = _('You can create a new one or check them in your %{pat_link_start}Personal Access Tokens%{pat_link_end} settings').html_safe % { pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe } diff --git a/app/views/notify/access_token_about_to_expire_email.text.erb b/app/views/notify/access_token_about_to_expire_email.text.erb new file mode 100644 index 00000000000..5e6bd68d33f --- /dev/null +++ b/app/views/notify/access_token_about_to_expire_email.text.erb @@ -0,0 +1,5 @@ +<%= _('Hi %{username}!') % { username: sanitize_name(@user.name) } %> + +<%= _('One or more of your personal access tokens will expire in %{days_to_expire} days or less.') % { days_to_expire: @days_to_expire} %> + +<%= _('You can create a new one or check them in your Personal Access Tokens settings %{pat_link}') % { pat_link: @target_url } %> diff --git a/app/views/profiles/accounts/_providers.html.haml b/app/views/profiles/accounts/_providers.html.haml index 068f9cc70f7..a87191d0fa4 100644 --- a/app/views/profiles/accounts/_providers.html.haml +++ b/app/views/profiles/accounts/_providers.html.haml @@ -16,6 +16,6 @@ %a.provider-btn = s_('Profiles|Active') - elsif link_allowed - = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'provider-btn not-active' do + = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'provider-btn gl-text-blue-500' do = s_('Profiles|Connect') = render_if_exists 'profiles/accounts/group_saml_unlink_buttons', group_saml_identities: group_saml_identities diff --git a/app/views/profiles/gpg_keys/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml index f8351644df5..2de5cf2f506 100644 --- a/app/views/profiles/gpg_keys/_key.html.haml +++ b/app/views/profiles/gpg_keys/_key.html.haml @@ -5,7 +5,7 @@ - key.emails_with_verified_status.map do |email, verified| = render partial: 'shared/email_with_badge', locals: { email: email, verified: verified } - .description + %span.text-truncate %code= key.fingerprint - if key.subkeys.present? .subkeys diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml index b9d73d89334..0e94e6563fd 100644 --- a/app/views/profiles/keys/_key.html.haml +++ b/app/views/profiles/keys/_key.html.haml @@ -10,7 +10,7 @@ .key-list-item-info = link_to path_to_key(key, is_admin), class: "title" do = key.title - .description + %span.text-truncate = key.fingerprint .last-used-at last used: diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml index 0ef01dec493..02f1a267044 100644 --- a/app/views/profiles/keys/_key_details.html.haml +++ b/app/views/profiles/keys/_key_details.html.haml @@ -17,11 +17,21 @@ .col-md-8 = form_errors(@key, type: 'key') unless @key.valid? - %p - %span.light= _('Fingerprint:') - %code.key-fingerprint= @key.fingerprint %pre.well-pre = @key.key + .card + .card-header + = _('Fingerprints') + %ul.content-list + %li + %span.light= 'MD5:' + %code.key-fingerprint= @key.fingerprint + - if @key.fingerprint_sha256.present? + %li + %span.light= 'SHA256:' + %code.key-fingerprint= @key.fingerprint_sha256 + + .col-md-12 .float-right - if @key.can_delete? diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml index 0e2b0430fec..af6fa6b1b61 100644 --- a/app/views/profiles/passwords/edit.html.haml +++ b/app/views/profiles/passwords/edit.html.haml @@ -32,4 +32,4 @@ .prepend-top-default.append-bottom-default = f.submit _('Save password'), class: "btn btn-success append-right-10", data: { qa_selector: 'save_password_button' } - unless @user.password_automatically_set? - = link_to _('I forgot my password'), reset_profile_password_path, method: :put, class: "account-btn-link" + = link_to _('I forgot my password'), reset_profile_password_path, method: :put diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 5501e63e027..4a2d0a4f8ce 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -26,7 +26,7 @@ - else %p - help_link_start = '<a href="%{url}" target="_blank">' % { url: help_page_path('user/profile/account/two_factor_authentication') } - - register_2fa_token = _('Install a soft token authenticator like %{free_otp_link} or Google Authenticator from your application repository and scan this QR code. More information is available in the %{help_link_start}documentation%{help_link_end}.') % { free_otp_link:'<a href="https://freeotp.github.io/">FreeOTP</a>', help_link_start:help_link_start, help_link_end:'</a>' } + - register_2fa_token = _('Install a soft token authenticator like %{free_otp_link} or Google Authenticator from your application repository and use that app to scan this QR code. More information is available in the %{help_link_start}documentation%{help_link_end}.') % { free_otp_link:'<a href="https://freeotp.github.io/">FreeOTP</a>', help_link_start:help_link_start, help_link_end:'</a>' } = register_2fa_token.html_safe .row.append-bottom-10 .col-md-4 diff --git a/app/views/projects/_archived_notice.html.haml b/app/views/projects/_archived_notice.html.haml new file mode 100644 index 00000000000..522693ae24a --- /dev/null +++ b/app/views/projects/_archived_notice.html.haml @@ -0,0 +1,5 @@ +- if project.archived? + .text-warning.center.prepend-top-20 + %p + = icon("exclamation-triangle fw") + = _('Archived project! Repository and other project resources are read only') diff --git a/app/views/projects/_remove.html.haml b/app/views/projects/_remove.html.haml new file mode 100644 index 00000000000..6c84fbfeeb3 --- /dev/null +++ b/app/views/projects/_remove.html.haml @@ -0,0 +1,10 @@ +- return unless can?(current_user, :remove_project, project) + +.sub-section + %h4.danger-title= _('Remove project') + %p + %strong= _('Removing the project will delete its repository and all related resources including issues, merge requests etc.') + = form_tag(project_path(project), method: :delete) do + %p + %strong= _('Removed projects cannot be restored!') + = button_to _('Remove project'), '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_project_message(project) } diff --git a/app/views/projects/_visibility_modal.html.haml b/app/views/projects/_visibility_modal.html.haml new file mode 100644 index 00000000000..3ef93a40137 --- /dev/null +++ b/app/views/projects/_visibility_modal.html.haml @@ -0,0 +1,30 @@ +- strong_start = "<strong>".html_safe +- strong_end = "</strong>".html_safe + +.modal.js-confirm-project-visiblity{ tabindex: -1 } + .modal-dialog + .modal-content + .modal-header + %h3.page-title= _('Reduce this project’s visibility?') + %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } + %span{ "aria-hidden": true }= sprite_icon("close", size: 16) + .modal-body + %p + - if @project.group + = _("You're about to reduce the visibility of the project %{strong_start}%{project_name}%{strong_end} in %{strong_start}%{group_name}%{strong_end}.").html_safe % { project_name: @project.name, group_name: @project.group.name, strong_start: strong_start, strong_end: strong_end } + - else + = _("You're about to reduce the visibility of the project %{strong_start}%{project_name}%{strong_end}.").html_safe % { project_name: @project.name, strong_start: strong_start, strong_end: strong_end } + %p + = _('Once you confirm and press "Reduce project visibility":') + %ul + %li + = ("Current forks will keep their visibility level but their fork relationship with this project will be %{strong_start}removed%{strong_end}.").html_safe % { strong_start: strong_start, strong_end: strong_end } + %label{ for: "confirm_path_input" } + = ("To confirm, type %{phrase_code}").html_safe % { phrase_code: '<code class="js-confirm-danger-match">%{phrase_name}</code>'.html_safe % { phrase_name: @project.full_path } } + .form-group + = text_field_tag 'confirm_path_input', '', class: 'form-control js-confirm-danger-input qa-confirm-input' + .form-actions.clearfix + .pull-right + %button.btn.btn-default{ type: "button", "data-dismiss": "modal" } + = _('Cancel') + = submit_tag _('Reduce project visibility'), class: "btn btn-danger js-confirm-danger-submit qa-confirm-button", disabled: true diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml index 6a7cb1499c5..7abac2d14e4 100644 --- a/app/views/projects/artifacts/browse.html.haml +++ b/app/views/projects/artifacts/browse.html.haml @@ -15,7 +15,7 @@ %li.breadcrumb-item = link_to truncate(title, length: 40), browse_project_job_artifacts_path(@project, @build, path) - .tree-controls + .tree-controls< = link_to download_project_job_artifacts_path(@project, @build), rel: 'nofollow', download: '', class: 'btn btn-default download' do = sprite_icon('download') diff --git a/app/views/projects/blob/_breadcrumb.html.haml b/app/views/projects/blob/_breadcrumb.html.haml index a4fb5f6ba88..e611df8df2a 100644 --- a/app/views/projects/blob/_breadcrumb.html.haml +++ b/app/views/projects/blob/_breadcrumb.html.haml @@ -17,21 +17,19 @@ - else = link_to title, project_tree_path(@project, tree_join(@ref, path)) - .tree-controls + .tree-controls< = render 'projects/find_file_link' + -# only show normal/blame view links for text files + - if blob.readable_text? + - if blame + = link_to 'Normal view', project_blob_path(@project, @id), + class: 'btn' + - else + = link_to 'Blame', project_blame_path(@project, @id), + class: 'btn js-blob-blame-link' unless blob.empty? - .btn-group{ role: "group" }< - -# only show normal/blame view links for text files - - if blob.readable_text? - - if blame - = link_to 'Normal view', project_blob_path(@project, @id), - class: 'btn' - - else - = link_to 'Blame', project_blame_path(@project, @id), - class: 'btn js-blob-blame-link' unless blob.empty? + = link_to 'History', project_commits_path(@project, @id), + class: 'btn' - = link_to 'History', project_commits_path(@project, @id), - class: 'btn' - - = link_to 'Permalink', project_blob_path(@project, - tree_join(@commit.sha, @path)), class: 'btn js-data-file-blob-permalink-url' + = link_to 'Permalink', project_blob_path(@project, + tree_join(@commit.sha, @path)), class: 'btn js-data-file-blob-permalink-url' diff --git a/app/views/projects/blob/viewers/_openapi.html.haml b/app/views/projects/blob/viewers/_openapi.html.haml new file mode 100644 index 00000000000..ce8030cf2d2 --- /dev/null +++ b/app/views/projects/blob/viewers/_openapi.html.haml @@ -0,0 +1 @@ +.file-content#js-openapi-viewer{ data: { endpoint: blob_raw_path } } diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml index bbe0a2c97fd..f1a7528065a 100644 --- a/app/views/projects/buttons/_dropdown.html.haml +++ b/app/views/projects/buttons/_dropdown.html.haml @@ -7,7 +7,7 @@ - show_menu = can_create_issue || can_create_project_snippet || can_push_code || create_mr_from_new_fork || merge_project - if show_menu - .project-action-button.dropdown.inline + .project-action-button.dropdown.inline< %a.btn.dropdown-toggle.has-tooltip.qa-create-new-dropdown{ href: '#', title: _('Create new...'), 'data-toggle' => 'dropdown', 'data-container' => 'body', 'aria-label' => _('Create new...'), 'data-display' => 'static' } = icon('plus') = icon("caret-down") diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index e155e3758fb..3f1d44a488a 100644 --- a/app/views/projects/commits/show.html.haml +++ b/app/views/projects/commits/show.html.haml @@ -13,7 +13,7 @@ %ul.breadcrumb.repo-breadcrumb = commits_breadcrumbs - .tree-controls.d-none.d-sm-none.d-md-block + .tree-controls.d-none.d-sm-none.d-md-block< - if @merge_request.present? .control = link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'btn' diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 328fdd0be10..1c18487f688 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -21,7 +21,9 @@ %input{ name: 'update_section', type: 'hidden', value: 'js-shared-permissions' } %template.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data_json(@project) .js-project-permissions-form - = f.submit _('Save changes'), class: "btn btn-success", data: { qa_selector: 'visibility_features_permissions_save_button' } + - if show_visibility_confirm_modal?(@project) + = render "visibility_modal" + = f.submit _('Save changes'), class: "btn btn-success #{('js-confirm-danger' if show_visibility_confirm_modal?(@project))}", data: { qa_selector: 'visibility_features_permissions_save_button', check_field_name: ("project[visibility_level]" if show_visibility_confirm_modal?(@project)), check_compare_value: @project.visibility_level } %section.qa-merge-request-settings.rspec-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)] } .settings-header @@ -71,23 +73,7 @@ = render 'export', project: @project - - if can? current_user, :archive_project, @project - .sub-section - %h4.warning-title - - if @project.archived? - = _('Unarchive project') - - else - = _('Archive project') - - if @project.archived? - %p= _("Unarchiving the project will restore people's ability to make changes to it. The repository can be committed to, and issues, comments and other entities can be created. <strong>Once active this project shows up in the search and on the dashboard.</strong>").html_safe - = link_to _('Unarchive project'), unarchive_project_path(@project), - data: { confirm: _("Are you sure that you want to unarchive this project?"), qa_selector: 'unarchive_project_link' }, - method: :post, class: "btn btn-success" - - else - %p= _("Archiving the project will make it entirely read-only. It is hidden from the dashboard and doesn't show up in searches. <strong>The repository cannot be committed to, and no issues, comments or other entities can be created.</strong>").html_safe - = link_to _('Archive project'), archive_project_path(@project), - data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link' }, - method: :post, class: "btn btn-warning" + = render_if_exists 'projects/settings/archive' .sub-section.rename-repository %h4.warning-title= _('Change path') = render 'projects/errors' @@ -133,14 +119,7 @@ %strong= _('Once removed, the fork relationship cannot be restored and you will no longer be able to send merge requests to the source.') = button_to _('Remove fork relationship'), '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_fork_project_warning_message(@project) } - - if can?(current_user, :remove_project, @project) - .sub-section - %h4.danger-title= _('Remove project') - %p= _('Removing the project will delete its repository and all related resources including issues, merge requests etc.') - = form_tag(project_path(@project), method: :delete) do - %p - %strong= _('Removed projects cannot be restored!') - = button_to _('Remove project'), '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_project_message(@project) } + = render 'remove', project: @project .save-project-loader.hide .center diff --git a/app/views/projects/environments/empty_logs.html.haml b/app/views/projects/environments/empty_logs.html.haml deleted file mode 100644 index 602dc908b75..00000000000 --- a/app/views/projects/environments/empty_logs.html.haml +++ /dev/null @@ -1,14 +0,0 @@ -- page_title _('Pod logs') - -.row.empty-state - .col-sm-12 - .svg-content - = image_tag 'illustrations/operations_log_pods_empty.svg' - .col-12 - .text-content - %h4.text-center - = s_('Environments|No deployed environments') - %p.state-description.text-center - = s_('Logs|To see the pod logs, deploy your code to an environment.') - .text-center - = link_to s_('Environments|Learn about environments'), help_page_path('ci/environments'), class: 'btn btn-success' diff --git a/app/views/projects/environments/folder.html.haml b/app/views/projects/environments/folder.html.haml index f85c57d9aa1..cd24c30e46f 100644 --- a/app/views/projects/environments/folder.html.haml +++ b/app/views/projects/environments/folder.html.haml @@ -1,3 +1,5 @@ -- page_title _("Environments") +- add_to_breadcrumbs _("Environments"), project_environments_path(@project) +- breadcrumb_title _("Folder/%{name}") % { name: @folder } +- page_title _("Environments in %{name}") % { name: @folder } #environments-folder-list-view{ data: { environments_data: environments_folder_list_view_data } } diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index c4c39c227c6..62b1c140794 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -5,7 +5,7 @@ - content_for :page_specific_javascripts do = stylesheet_link_tag 'page_bundles/xterm' -- if can?(current_user, :stop_environment, @environment) +- if @environment.available? && can?(current_user, :stop_environment, @environment) #stop-environment-modal.modal.fade{ tabindex: -1 } .modal-dialog .modal-content @@ -40,7 +40,7 @@ = render 'projects/environments/metrics_button', environment: @environment - if can?(current_user, :update_environment, @environment) = link_to _('Edit'), edit_project_environment_path(@project, @environment), class: 'btn' - - if can?(current_user, :stop_environment, @environment) + - if @environment.available? && can?(current_user, :stop_environment, @environment) = button_tag class: 'btn btn-danger', type: 'button', data: { toggle: 'modal', target: '#stop-environment-modal' } do = sprite_icon('stop') diff --git a/app/views/projects/hook_logs/_index.html.haml b/app/views/projects/hook_logs/_index.html.haml index 3e54c3ca9f8..ada986dd969 100644 --- a/app/views/projects/hook_logs/_index.html.haml +++ b/app/views/projects/hook_logs/_index.html.haml @@ -28,7 +28,7 @@ %td.light = time_ago_with_tooltip(hook_log.created_at) %td - = link_to 'View details', project_hook_hook_log_path(project, hook, hook_log) + = link_to 'View details', hook_log.present.details_path = paginate hook_logs, theme: 'gitlab' diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml index bd8ca5e7d70..a8796cd7b1c 100644 --- a/app/views/projects/hook_logs/show.html.haml +++ b/app/views/projects/hook_logs/show.html.haml @@ -3,7 +3,6 @@ %h4.prepend-top-0 Request details .col-lg-9 - - = link_to 'Resend Request', retry_project_hook_hook_log_path(@project, @hook, @hook_log), method: :post, class: "btn btn-default float-right prepend-left-10" + = link_to 'Resend Request', @hook_log.present.retry_path, method: :post, class: "btn btn-default float-right prepend-left-10" = render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log } diff --git a/app/views/projects/hooks/_index.html.haml b/app/views/projects/hooks/_index.html.haml index 0ab7863b77c..70f2fa0e758 100644 --- a/app/views/projects/hooks/_index.html.haml +++ b/app/views/projects/hooks/_index.html.haml @@ -1,23 +1,10 @@ .row.prepend-top-default .col-lg-4 - %h4.prepend-top-0 - = page_title - %p - #{link_to 'Webhooks', help_page_path('user/project/integrations/webhooks')} can be - used for binding events when something is happening within the project. + = render 'shared/web_hooks/title_and_docs', hook: @hook .col-lg-8.append-bottom-default = form_for @hook, as: :hook, url: polymorphic_path([@project.namespace.becomes(Namespace), @project, :hooks]) do |f| = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook } = f.submit 'Add webhook', class: 'btn btn-success' - %hr - %h5.prepend-top-default - Webhooks (#{@hooks.count}) - - if @hooks.any? - %ul.content-list - - @hooks.each do |hook| - = render 'project_hook', hook: hook - - else - %p.settings-message.text-center.append-bottom-0 - No webhooks found, add one in the form above. + = render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml index 57311284e11..c1fdf619eb5 100644 --- a/app/views/projects/hooks/edit.html.haml +++ b/app/views/projects/hooks/edit.html.haml @@ -1,19 +1,17 @@ -- page_title 'Integrations' +- add_to_breadcrumbs _('ProjectService|Integrations'), namespace_project_settings_integrations_path +- page_title _('Edit Project Hook') .row.prepend-top-default .col-lg-3 - %h4.prepend-top-0 - = page_title - %p - #{link_to 'Webhooks', help_page_path('user/project/integrations/webhooks')} can be - used for binding events when something is happening within the project. + = render 'shared/web_hooks/title_and_docs', hook: @hook + .col-lg-9.append-bottom-default = form_for [@project.namespace.becomes(Namespace), @project, @hook], as: :hook, url: project_hook_path(@project, @hook) do |f| = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook } - = f.submit 'Save changes', class: 'btn btn-success' - = render 'shared/web_hooks/test_button', triggers: ProjectHook.triggers, hook: @hook - = link_to 'Remove', project_hook_path(@project, @hook), method: :delete, class: 'btn btn-remove float-right', data: { confirm: 'Are you sure?' } + %span>= f.submit 'Save changes', class: 'btn btn-success append-right-8' + = render 'shared/web_hooks/test_button', hook: @hook + = link_to _('Delete'), project_hook_path(@project, @hook), method: :delete, class: 'btn btn-remove float-right', data: { confirm: _('Are you sure?') } %hr diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index 8d3e54dc455..eb76326602f 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -28,7 +28,7 @@ %ul#create-merge-request-dropdown.create-merge-request-dropdown-menu.dropdown-menu.dropdown-menu-right.gl-show-field-errors{ class: ("create-confidential-merge-request-dropdown-menu" if can_create_confidential_merge_request?), data: { dropdown: true } } - if can_create_merge_request %li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', text: create_mr_text } } - .menu-item + .menu-item.text-nowrap = icon('check', class: 'icon') - if can_create_confidential_merge_request? = _('Create confidential merge request and branch') diff --git a/app/views/projects/merge_requests/_awards_block.html.haml b/app/views/projects/merge_requests/_awards_block.html.haml new file mode 100644 index 00000000000..1eab28a2ff3 --- /dev/null +++ b/app/views/projects/merge_requests/_awards_block.html.haml @@ -0,0 +1,5 @@ +.content-block.content-block-small.emoji-list-container.js-noteable-awards + = render 'award_emoji/awards_block', awardable: @merge_request, inline: true do + - if mr_tabs_position_enabled? + .ml-auto.mt-auto.mb-auto + = render "projects/merge_requests/discussion_filter" diff --git a/app/views/projects/merge_requests/_description.html.haml b/app/views/projects/merge_requests/_description.html.haml new file mode 100644 index 00000000000..354a384b647 --- /dev/null +++ b/app/views/projects/merge_requests/_description.html.haml @@ -0,0 +1,9 @@ +%div + - if @merge_request.description.present? + .description.qa-description{ class: can?(current_user, :update_merge_request, @merge_request) ? 'js-task-list-container' : '' } + .md + = markdown_field(@merge_request, :description) + %textarea.hidden.js-task-list-field + = @merge_request.description + + = edited_time_ago_with_tooltip(@merge_request, placement: 'bottom') diff --git a/app/views/projects/merge_requests/_discussion_filter.html.haml b/app/views/projects/merge_requests/_discussion_filter.html.haml new file mode 100644 index 00000000000..96886661a8d --- /dev/null +++ b/app/views/projects/merge_requests/_discussion_filter.html.haml @@ -0,0 +1,2 @@ +#js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@merge_request), + notes_filters: UserPreference.notes_filters.to_json } } diff --git a/app/views/projects/merge_requests/_mr_box.html.haml b/app/views/projects/merge_requests/_mr_box.html.haml index 4f09f47d795..ec78b040167 100644 --- a/app/views/projects/merge_requests/_mr_box.html.haml +++ b/app/views/projects/merge_requests/_mr_box.html.haml @@ -1,13 +1,6 @@ -.detail-page-description - %h2.title.qa-title +.detail-page-description{ class: ("py-2" if mr_tabs_position_enabled?) } + %h2.title.qa-title{ class: ("mb-0" if mr_tabs_position_enabled?) } = markdown_field(@merge_request, :title) - %div - - if @merge_request.description.present? - .description.qa-description{ class: can?(current_user, :update_merge_request, @merge_request) ? 'js-task-list-container' : '' } - .md - = markdown_field(@merge_request, :description) - %textarea.hidden.js-task-list-field - = @merge_request.description - - = edited_time_ago_with_tooltip(@merge_request, placement: 'bottom') + - unless mr_tabs_position_enabled? + = render "projects/merge_requests/description" diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index 92e34b3ceda..d1e8dc3a834 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -1,16 +1,18 @@ +- @no_breadcrumb_border = true - can_update_merge_request = can?(current_user, :update_merge_request, @merge_request) - can_reopen_merge_request = can?(current_user, :reopen_merge_request, @merge_request) +- state_human_name, state_icon_name = state_name_with_icon(@merge_request) - if @merge_request.closed_without_fork? .alert.alert-danger The source project of this merge request has been removed. -.detail-page-header +.detail-page-header{ class: ("border-bottom-0 pt-0 pb-0" if mr_tabs_position_enabled?) } .detail-page-header-body .issuable-status-box.status-box{ class: status_box_class(@merge_request) } - = sprite_icon(@merge_request.state_icon_name, size: 16, css_class: 'd-block d-sm-none') + = sprite_icon(state_icon_name, size: 16, css_class: 'd-block d-sm-none') %span.d-none.d-sm-block - = @merge_request.state_human_name + = state_human_name .issuable-meta - if @merge_request.discussion_locked? diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml new file mode 100644 index 00000000000..3fe6f0a6640 --- /dev/null +++ b/app/views/projects/merge_requests/_widget.html.haml @@ -0,0 +1,14 @@ +- if @merge_request.source_branch_exists? + = render "projects/merge_requests/how_to_merge" + += javascript_tag nonce: true do + :plain + window.gl = window.gl || {}; + window.gl.mrWidgetData = #{serialize_issuable(@merge_request, serializer: 'widget', issues_links: true)} + + window.gl.mrWidgetData.squash_before_merge_help_path = '#{help_page_path("user/project/merge_requests/squash_and_merge")}'; + window.gl.mrWidgetData.troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/reviewing_and_managing_merge_requests.md', anchor: 'troubleshooting')}'; + window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.html', anchor: 'security-approvals-in-merge-requests-ultimate')}'; + window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/merge_request_approvals', anchor: 'eligible-approvers')}'; + +#js-vue-mr-widget.mr-widget diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index dee6bc8bae4..310cd355d22 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -14,56 +14,54 @@ .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } } = render "projects/merge_requests/mr_box" - - if @merge_request.source_branch_exists? - = render "projects/merge_requests/how_to_merge" - - = javascript_tag nonce: true do - :plain - window.gl = window.gl || {}; - window.gl.mrWidgetData = #{serialize_issuable(@merge_request, serializer: 'widget', issues_links: true)} - - window.gl.mrWidgetData.squash_before_merge_help_path = '#{help_page_path("user/project/merge_requests/squash_and_merge")}'; - window.gl.mrWidgetData.troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/index.md', anchor: 'troubleshooting')}'; - window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.html', anchor: 'security-approvals-in-merge-requests-ultimate')}'; - - #js-vue-mr-widget.mr-widget - - .content-block.content-block-small.emoji-list-container.js-noteable-awards - = render 'award_emoji/awards_block', awardable: @merge_request, inline: true + - unless mr_tabs_position_enabled? + = render "projects/merge_requests/widget" + = render "projects/merge_requests/awards_block" .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') } .merge-request-tabs-container %ul.merge-request-tabs.nav-tabs.nav.nav-links - %li.notes-tab{ data: { qa_selector: 'notes_tab'} } + = render "projects/merge_requests/tabs/tab", class: "notes-tab", qa_selector: "notes_tab" do = tab_link_for @merge_request, :show, force_link: @commit.present? do - = _("Discussion") + - if mr_tabs_position_enabled? + = _("Overview") + - else + = _("Discussion") %span.badge.badge-pill= @merge_request.related_notes.user.count - if @merge_request.source_project - %li.commits-tab + = render "projects/merge_requests/tabs/tab", name: "commits", class: "commits-tab" do = tab_link_for @merge_request, :commits do = _("Commits") %span.badge.badge-pill= @commits_count - if number_of_pipelines.nonzero? - %li.pipelines-tab + = render "projects/merge_requests/tabs/tab", name: "pipelines", class: "pipelines-tab" do = tab_link_for @merge_request, :pipelines do = _("Pipelines") %span.badge.badge-pill.js-pipelines-mr-count= number_of_pipelines - %li.diffs-tab.qa-diffs-tab + = render "projects/merge_requests/tabs/tab", name: "diffs", class: "diffs-tab qa-diffs-tab", id: "diffs-tab" do = tab_link_for @merge_request, :diffs do = _("Changes") %span.badge.badge-pill= @merge_request.diff_size + - if mr_tabs_position_enabled? && show_tabs_feature_highlight? + .js-tabs-feature-highlight{ data: { dismiss_endpoint: user_callouts_path, feature_id: UserCalloutsHelper::TABS_POSITION_HIGHLIGHT } } .d-flex.flex-wrap.align-items-center.justify-content-lg-end - #js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@merge_request), - notes_filters: UserPreference.notes_filters.to_json } } + - unless mr_tabs_position_enabled? + = render "projects/merge_requests/discussion_filter" #js-vue-discussion-counter .tab-content#diff-notes-app #js-diff-file-finder - #notes.notes.tab-pane.voting_notes + = render "projects/merge_requests/tabs/pane", id: "notes", class: "notes voting_notes" do .row %section.col-md-12 %script.js-notes-data{ type: "application/json" }= initial_notes_data(true).to_json.html_safe .issuable-discussion.js-vue-notes-event + - if mr_tabs_position_enabled? + - if @merge_request.description.present? + .detail-page-description + = render "projects/merge_requests/description" + = render "projects/merge_requests/widget" + = render "projects/merge_requests/awards_block" #js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request).to_json, noteable_data: serialize_issuable(@merge_request, serializer: 'noteable'), noteable_type: 'MergeRequest', @@ -71,13 +69,15 @@ help_page_path: suggest_changes_help_path, current_user_data: @current_user_data} } - #commits.commits.tab-pane + = render "projects/merge_requests/tabs/pane", name: "commits", id: "commits", class: "commits" do -# This tab is always loaded via AJAX - #pipelines.pipelines.tab-pane + = render "projects/merge_requests/tabs/pane", name: "pipelines", id: "pipelines", class: "pipelines" do - if number_of_pipelines.nonzero? = render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request) - #js-diffs-app.diffs.tab-pane{ data: { "is-locked" => @merge_request.discussion_locked?, + = render "projects/merge_requests/tabs/pane", name: "diffs", id: "js-diffs-app", class: "diffs", data: { "is-locked": @merge_request.discussion_locked?, endpoint: diffs_project_merge_request_path(@project, @merge_request, 'json', request.query_parameters), + endpoint_metadata: diffs_metadata_project_json_merge_request_path(@project, @merge_request, 'json', request.query_parameters), + endpoint_batch: diffs_batch_project_json_merge_request_path(@project, @merge_request, 'json', request.query_parameters), help_page_path: suggest_changes_help_path, current_user_data: @current_user_data, project_path: project_path(@merge_request.project), @@ -85,7 +85,7 @@ is_fluid_layout: fluid_layout.to_s, dismiss_endpoint: user_callouts_path, show_suggest_popover: show_suggest_popover?.to_s, - show_whitespace_default: @show_whitespace_default.to_s } } + show_whitespace_default: @show_whitespace_default.to_s } .mr-loading-status = spinner diff --git a/app/views/projects/merge_requests/tabs/_pane.html.haml b/app/views/projects/merge_requests/tabs/_pane.html.haml new file mode 100644 index 00000000000..1a88d5f5134 --- /dev/null +++ b/app/views/projects/merge_requests/tabs/_pane.html.haml @@ -0,0 +1,7 @@ +- tab_name = local_assigns.fetch(:name, nil) +- tab_id = local_assigns.fetch(:id, nil) +- tab_class = local_assigns.fetch(:class, nil) +- tab_data = local_assigns.fetch(:data, nil) + +.tab-pane{ id: tab_id, class: tab_class, style: ("display: block" if params[:tab] == tab_name), data: tab_data } + = yield diff --git a/app/views/projects/merge_requests/tabs/_tab.html.haml b/app/views/projects/merge_requests/tabs/_tab.html.haml new file mode 100644 index 00000000000..dcd8db90509 --- /dev/null +++ b/app/views/projects/merge_requests/tabs/_tab.html.haml @@ -0,0 +1,7 @@ +- tab_name = local_assigns.fetch(:name, nil) +- tab_class = local_assigns.fetch(:class, nil) +- qa_selector = local_assigns.fetch(:qa_selector, nil) +- id = local_assigns.fetch(:id, nil) + +%li{ class: [tab_class, ("active" if params[:tab] == tab_name)], id: id, data: { qa_selector: qa_selector } } + = yield diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml index 4676c7399f1..6d196b06135 100644 --- a/app/views/projects/pages/_list.html.haml +++ b/app/views/projects/pages/_list.html.haml @@ -21,11 +21,11 @@ %span.badge.badge-danger = s_('GitLabPages|Expired') %div - = link_to s_('GitLabPages|Edit'), edit_project_pages_domain_path(@project, domain), class: "btn btn-sm btn-grouped btn-success btn-inverted" + = link_to s_('GitLabPages|Edit'), project_pages_domain_path(@project, domain), class: "btn btn-sm btn-grouped btn-success btn-inverted" = link_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?')}, method: :delete, class: "btn btn-remove btn-sm btn-grouped" - if verification_enabled && domain.unverified? %li.list-group-item.bs-callout-warning - - details_link_start = "<a href='#{edit_project_pages_domain_path(@project, domain)}'>".html_safe + - details_link_start = "<a href='#{project_pages_domain_path(@project, domain)}'>".html_safe - details_link_end = '</a>'.html_safe = s_('GitLabPages|%{domain} is not verified. To learn how to verify ownership, visit your %{link_start}domain details%{link_end}.').html_safe % { domain: domain.domain, link_start: details_link_start, diff --git a/app/views/projects/pages_domains/edit.html.haml b/app/views/projects/pages_domains/edit.html.haml deleted file mode 100644 index a08be65d7e4..00000000000 --- a/app/views/projects/pages_domains/edit.html.haml +++ /dev/null @@ -1,21 +0,0 @@ -- add_to_breadcrumbs _("Pages"), project_pages_path(@project) -- breadcrumb_title @domain.domain -- page_title @domain.domain - -- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled? - -- if verification_enabled && @domain.unverified? - = content_for :flash_message do - .alert.alert-warning - .container-fluid.container-limited - = _("This domain is not verified. You will need to verify ownership before access is enabled.") - -%h3.page-title - = _('Pages Domain') -= render 'projects/pages_domains/helper_text' -%div - = form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'fieldset-form' } do |f| - = render 'form', { f: f } - .form-actions.d-flex.justify-content-between - = f.submit _('Save Changes'), class: "btn btn-success" - = link_to _('Cancel'), project_pages_path(@project), class: 'btn btn-default btn-inverse' diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml index 8eec3d51835..a08be65d7e4 100644 --- a/app/views/projects/pages_domains/show.html.haml +++ b/app/views/projects/pages_domains/show.html.haml @@ -1,7 +1,6 @@ - add_to_breadcrumbs _("Pages"), project_pages_path(@project) - breadcrumb_title @domain.domain -- page_title "#{@domain.domain}", _('Pages Domains') -- dns_record = "#{@domain.domain} CNAME #{@domain.project.pages_subdomain}.#{Settings.pages.host}." +- page_title @domain.domain - verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled? @@ -11,51 +10,12 @@ .container-fluid.container-limited = _("This domain is not verified. You will need to verify ownership before access is enabled.") -%h3.page-title.with-button - = link_to _('Edit'), edit_project_pages_domain_path(@project, @domain), class: 'btn btn-success float-right' - = _("Pages Domain") - -.table-holder - %table.table - %tr - %td - = _("Domain") - %td - = external_link(@domain.url, @domain.url) - %tr - %td - = _("DNS") - %td - .input-group - = text_field_tag :domain_dns, dns_record , class: "monospace js-select-on-focus form-control", readonly: true - .input-group-append - = clipboard_button(target: '#domain_dns', class: 'btn-default input-group-text d-none d-sm-block') - %p.form-text.text-muted - = _("To access this domain create a new DNS record") - - - if verification_enabled - - verification_record = "#{@domain.verification_domain} TXT #{@domain.keyed_verification_code}" - %tr - %td - = _("Verification status") - %td - = form_tag verify_project_pages_domain_path(@project, @domain) do - .status-badge - - text, status = @domain.unverified? ? [_('Unverified'), 'badge-danger'] : [_('Verified'), 'badge-success'] - .badge{ class: status } - = text - %button.btn.has-tooltip{ type: "submit", data: { container: 'body' }, title: _("Retry verification") } - = sprite_icon('redo') - .input-group - = text_field_tag :domain_verification, verification_record, class: "monospace js-select-on-focus form-control", readonly: true - .input-group-append - = clipboard_button(target: '#domain_verification', class: 'btn-default d-none d-sm-block') - %p.form-text.text-muted - - link_to_help = link_to(_('verify ownership'), help_page_path('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: '4-verify-the-domains-ownership')) - = _("To %{link_to_help} of your domain, add the above key to a TXT record within to your DNS configuration.").html_safe % { link_to_help: link_to_help } - - %tr - %td - = _("Certificate") - %td - = render 'lets_encrypt_callout', auto_ssl_available_and_enabled: false +%h3.page-title + = _('Pages Domain') += render 'projects/pages_domains/helper_text' +%div + = form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'fieldset-form' } do |f| + = render 'form', { f: f } + .form-actions.d-flex.justify-content-between + = f.submit _('Save Changes'), class: "btn btn-success" + = link_to _('Cancel'), project_pages_path(@project), class: 'btn btn-default btn-inverse' diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 4eec81c9125..ce6ae765de9 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -20,6 +20,11 @@ .well-segment.qa-pipeline-badges .icon-container = sprite_icon('flag') + - if @pipeline.child? + %span.js-pipeline-child.badge.badge-primary.has-tooltip{ title: s_("Pipelines|This is a child pipeline within the parent pipeline") } + = s_('Pipelines|Child pipeline') + = surround '(', ')' do + = link_to s_('Pipelines|parent'), pipeline_path(@pipeline.triggered_by_pipeline), class: 'text-white text-underline' - if @pipeline.latest? %span.js-pipeline-url-latest.badge.badge-success.has-tooltip{ title: _("Latest pipeline for the most recent commit on this branch") } latest diff --git a/app/views/projects/registry/settings/_index.haml b/app/views/projects/registry/settings/_index.haml new file mode 100644 index 00000000000..e1eed93664e --- /dev/null +++ b/app/views/projects/registry/settings/_index.haml @@ -0,0 +1,2 @@ +#js-registry-settings{ data: { registry_settings_endpoint: '', + help_page_path: help_page_path('user/project/operations/linking_to_an_external_dashboard') } } diff --git a/app/views/projects/services/edit.html.haml b/app/views/projects/services/edit.html.haml index 1e7903535c6..e3e8a312431 100644 --- a/app/views/projects/services/edit.html.haml +++ b/app/views/projects/services/edit.html.haml @@ -1,8 +1,10 @@ - breadcrumb_title @service.title - page_title @service.title, s_("ProjectService|Services") - add_to_breadcrumbs(s_("ProjectService|Settings"), edit_project_path(@project)) -- add_to_breadcrumbs(s_("ProjectService|Integrations"), namespace_project_settings_integrations_path) +- add_to_breadcrumbs(s_("ProjectService|Integrations"), project_settings_integrations_path(@project)) = render 'deprecated_message' if @service.deprecation_message = render 'form' +- if @web_hook_logs + = render partial: 'projects/hook_logs/index', locals: { hook: @service.service_hook, hook_logs: @web_hook_logs, project: @project } diff --git a/app/views/projects/settings/_archive.html.haml b/app/views/projects/settings/_archive.html.haml new file mode 100644 index 00000000000..3307c3775ec --- /dev/null +++ b/app/views/projects/settings/_archive.html.haml @@ -0,0 +1,18 @@ +- return unless can?(current_user, :archive_project, @project) + +.sub-section + %h4.warning-title + - if @project.archived? + = _('Unarchive project') + - else + = _('Archive project') + - if @project.archived? + %p= _("Unarchiving the project will restore people's ability to make changes to it. The repository can be committed to, and issues, comments, and other entities can be created. %{strong_start}Once active, this project shows up in the search and on the dashboard.%{strong_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + = link_to _('Unarchive project'), unarchive_project_path(@project), + data: { confirm: _("Are you sure that you want to unarchive this project?"), qa_selector: 'unarchive_project_link' }, + method: :post, class: "btn btn-success" + - else + %p= _("Archiving the project will make it entirely read only. It is hidden from the dashboard and doesn't show up in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + = link_to _('Archive project'), archive_project_path(@project), + data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link' }, + method: :post, class: "btn btn-warning" diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index ea815be23c1..a72179f40ad 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -55,7 +55,7 @@ = f.text_field :ci_config_path, class: 'form-control', placeholder: '.gitlab-ci.yml' %p.form-text.text-muted = _("The path to the CI configuration file. Defaults to <code>.gitlab-ci.yml</code>").html_safe - = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'custom-ci-config-path'), target: '_blank' + = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'custom-ci-configuration-path'), target: '_blank' %hr .form-group diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 862db23e856..38483f599b7 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -55,6 +55,18 @@ %button.btn.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p - = _("Triggers can force a specific branch or tag to get rebuilt with an API call. These tokens will impersonate their associated user including their access to projects and their project permissions.") + = _("Triggers can force a specific branch or tag to get rebuilt with an API call. These tokens will impersonate their associated user including their access to projects and their project permissions.") .settings-content = render 'projects/triggers/index' + +- if Feature.enabled?(:registry_retention_policies_settings, @project) + %section.settings.no-animate#js-registry-polcies{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _("Container Registry tag expiration policies") + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + %p + = _("Expiration policies for the Container Registry are a perfect solution for keeping the Registry space down while still enjoying the full power of GitLab CI/CD.") + .settings-content + = render 'projects/registry/settings/index' diff --git a/app/views/projects/settings/integrations/_project_hook.html.haml b/app/views/projects/settings/integrations/_project_hook.html.haml deleted file mode 100644 index ef445f2e139..00000000000 --- a/app/views/projects/settings/integrations/_project_hook.html.haml +++ /dev/null @@ -1,16 +0,0 @@ -%li - .row - .col-md-8.col-lg-7 - %strong.light-header= hook.url - %div - - ProjectHook.triggers.each_value do |event| - - if hook.public_send(event) - %span.badge.badge-gray.deploy-project-label= event.to_s.titleize - .col-md-4.col-lg-5.text-right-lg.prepend-top-5 - %span.append-right-10.inline - #{_("SSL Verification")}: #{hook.enable_ssl_verification ? _('enabled') : _('disabled')} - = link_to _('Edit'), edit_project_hook_path(@project, hook), class: 'btn btn-sm' - = render 'shared/web_hooks/test_button', triggers: ProjectHook.triggers, hook: hook, button_class: 'btn-sm' - = link_to project_hook_path(@project, hook), data: { confirm: _('Are you sure?') }, method: :delete, class: 'btn btn-transparent' do - %span.sr-only= _("Remove") - = icon('trash') diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index c5653c3dd5a..8f13806e8cd 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -18,11 +18,8 @@ - if can?(current_user, :download_code, @project) && @project.repository_languages.present? = repository_languages_bar(@project.repository_languages) - - if @project.archived? - .text-warning.center.prepend-top-20 - %p - = icon("exclamation-triangle fw") - #{ _('Archived project! Repository and other project resources are read-only') } + = render "archived_notice", project: @project + = render_if_exists "projects/marked_for_deletion_notice", project: @project - view_path = @project.default_view diff --git a/app/views/projects/snippets/_actions.html.haml b/app/views/projects/snippets/_actions.html.haml index ea963510a68..29bad50579c 100644 --- a/app/views/projects/snippets/_actions.html.haml +++ b/app/views/projects/snippets/_actions.html.haml @@ -4,7 +4,7 @@ - if can?(current_user, :update_project_snippet, @snippet) = link_to edit_project_snippet_path(@project, @snippet), class: "btn btn-grouped" do = _('Edit') - - if can?(current_user, :update_project_snippet, @snippet) + - if can?(current_user, :admin_project_snippet, @snippet) = link_to project_snippet_path(@project, @snippet), method: :delete, data: { confirm: _("Are you sure?") }, class: "btn btn-grouped btn-inverted btn-remove", title: _('Delete Snippet') do = _('Delete') - if can?(current_user, :create_project_snippet, @project) @@ -23,7 +23,7 @@ %li = link_to new_project_snippet_path(@project), title: _("New snippet") do = _('New snippet') - - if can?(current_user, :update_project_snippet, @snippet) + - if can?(current_user, :admin_project_snippet, @snippet) %li = link_to project_snippet_path(@project, @snippet), method: :delete, data: { confirm: _("Are you sure?") }, title: _('Delete Snippet') do = _('Delete') diff --git a/app/views/projects/snippets/index.html.haml b/app/views/projects/snippets/index.html.haml index 7682d01a5a1..0ce18d83d57 100644 --- a/app/views/projects/snippets/index.html.haml +++ b/app/views/projects/snippets/index.html.haml @@ -8,8 +8,7 @@ - if can?(current_user, :create_project_snippet, @project) .nav-controls - - if can?(current_user, :create_project_snippet, @project) - = link_to _("New snippet"), new_project_snippet_path(@project), class: "btn btn-success", title: _("New snippet") + = link_to _("New snippet"), new_project_snippet_path(@project), class: "btn btn-success", title: _("New snippet") = render 'shared/snippets/list' - else diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml index f495b4eaf30..768e4422206 100644 --- a/app/views/projects/snippets/show.html.haml +++ b/app/views/projects/snippets/show.html.haml @@ -3,13 +3,16 @@ - breadcrumb_title @snippet.to_reference - page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets") -= render 'shared/snippets/header' +- if Feature.enabled?(:snippets_vue) + #js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id} } +- else + = render 'shared/snippets/header' -.project-snippets - %article.file-holder.snippet-file-content - = render 'shared/snippets/blob' + .project-snippets + %article.file-holder.snippet-file-content + = render 'shared/snippets/blob' - .row-content-block.top-block.content-component-block - = render 'award_emoji/awards_block', awardable: @snippet, inline: true + .row-content-block.top-block.content-component-block + = render 'award_emoji/awards_block', awardable: @snippet, inline: true - #notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => true + #notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => true diff --git a/app/views/projects/stage/_stage.html.haml b/app/views/projects/stage/_stage.html.haml index f93994bebe3..387c8fb3234 100644 --- a/app/views/projects/stage/_stage.html.haml +++ b/app/views/projects/stage/_stage.html.haml @@ -1,3 +1,5 @@ +- stage = stage.present(current_user: current_user) + %tr %th{ colspan: 10 } %strong @@ -6,8 +8,8 @@ = ci_icon_for_status(stage.status) = stage.name.titleize -= render stage.statuses.latest_ordered, stage: false, ref: false, pipeline_link: false, allow_retry: true -= render stage.statuses.retried_ordered, stage: false, ref: false, pipeline_link: false, retried: true += render stage.latest_ordered_statuses, stage: false, ref: false, pipeline_link: false, allow_retry: true += render stage.retried_ordered_statuses, stage: false, ref: false, pipeline_link: false, retried: true %tr %td{ colspan: 10 } diff --git a/app/views/projects/tags/_tag.atom.builder b/app/views/projects/tags/_tag.atom.builder index 60d4b21b9d1..e4b2428d267 100644 --- a/app/views/projects/tags/_tag.atom.builder +++ b/app/views/projects/tags/_tag.atom.builder @@ -7,7 +7,7 @@ if commit xml.id tag_url xml.link href: tag_url xml.title truncate(tag.name, length: 80) - xml.summary strip_gpg_signature(tag.message) + xml.summary strip_signature(tag.message) xml.content markdown_field(release, :description), type: 'html' xml.updated release.updated_at.xmlschema if release xml.media :thumbnail, width: '40', height: '40', url: image_url(avatar_icon_for_email(commit.author_email)) diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index c7bd0262c54..75805192a61 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -1,7 +1,7 @@ - commit = @repository.commit(tag.dereferenced_target) - release = @releases.find { |release| release.tag == tag.name } -%li.flex-row - .row-main-content.str-truncated +%li.flex-row.allow-wrap + .row-main-content = icon('tag') = link_to tag.name, project_tag_path(@project, tag.name), class: 'item-title ref-name prepend-left-4' @@ -11,7 +11,7 @@ - if tag.message.present? - = strip_gpg_signature(tag.message) + = strip_signature(tag.message) - if commit .block-truncated @@ -26,7 +26,7 @@ = _("Release") = link_to release.name, project_releases_path(@project, anchor: release.tag), class: 'tag-release-link' - if release.description.present? - .description.md.prepend-top-default + .md.prepend-top-default = markdown_field(release, :description) .row-fixed-content.controls.flex-row diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml index 417cd7a8fee..8086d47479d 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -1,3 +1,7 @@ +- user = user_email = nil +- if @tag.tagger + - user_email = @tag.tagger.email + - user = User.find_by_any_email(user_email) - add_to_breadcrumbs s_('TagsPage|Tags'), project_tags_path(@project) - breadcrumb_title @tag.name - page_title @tag.name, s_('TagsPage|Tags') @@ -11,6 +15,24 @@ - if protected_tag?(@project, @tag) %span.badge.badge-success = s_('TagsPage|protected') + + - if user + = link_to user_path(user) do + %div + = user_avatar_without_link(user: user, size: 32, css_class: "mt-1 mb-1") + + %div + %strong= user.name + %div= user.to_reference + + - elsif user_email + = mail_to user_email do + %div + = user_avatar_without_link(user_email: user_email, size: 32, css_class: "mt-1 mb-1") + + %div{ :class => "clearfix" } + %strong= user_email + - if @commit = render 'projects/branches/commit', commit: @commit, project: @project - else @@ -33,7 +55,7 @@ - if @tag.message.present? %pre.wrap - = strip_gpg_signature(@tag.message) + = strip_signature(@tag.message) .append-bottom-default.prepend-top-default - if @release.description.present? diff --git a/app/views/projects/tree/_readme.html.haml b/app/views/projects/tree/_readme.html.haml index fef019e1b69..3e3804ae204 100644 --- a/app/views/projects/tree/_readme.html.haml +++ b/app/views/projects/tree/_readme.html.haml @@ -1,9 +1,10 @@ - if readme.rich_viewer %article.file-holder.readme-holder{ id: 'readme', class: [("limited-width-container" unless fluid_layout), ("js-show-on-root" if vue_file_list_enabled?)] } - .js-file-title.file-title - = blob_icon readme.mode, readme.name - = link_to project_blob_path(@project, tree_join(@ref, readme.path)) do - %strong - = readme.name + .js-file-title.file-title-flex-parent + .file-header-content + = blob_icon readme.mode, readme.name + = link_to project_blob_path(@project, tree_join(@ref, readme.path)) do + %strong + = readme.name = render 'projects/blob/viewer', viewer: readme.rich_viewer, viewer_url: project_blob_path(@project, tree_join(@ref, readme.path), viewer: :rich, format: :json) diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 127734ddfd7..2d987744dfd 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -75,7 +75,7 @@ = link_to new_project_tag_path(@project) do #{ _('New tag') } -.tree-controls +.tree-controls< = render_if_exists 'projects/tree/lock_link' - if vue_file_list_enabled? #js-tree-history-link.d-inline-block{ data: { history_link: project_commits_path(@project, @ref) } } @@ -85,20 +85,19 @@ = render 'projects/find_file_link' - if can_create_mr_from_fork - = succeed " " do - - if can_collaborate || current_user&.already_forked?(@project) - - if vue_file_list_enabled? - #js-tree-web-ide-link.d-inline-block - - else - = link_to ide_edit_path(@project, @ref, @path), class: 'btn btn-default qa-web-ide-button' do - = _('Web IDE') + - if can_collaborate || current_user&.already_forked?(@project) + - if vue_file_list_enabled? + #js-tree-web-ide-link.d-inline-block - else - = link_to '#modal-confirm-fork', class: 'btn btn-default qa-web-ide-button', data: { target: '#modal-confirm-fork', toggle: 'modal'} do + = link_to ide_edit_path(@project, @ref, @path), class: 'btn btn-default qa-web-ide-button' do = _('Web IDE') - = render 'shared/confirm_fork_modal', fork_path: ide_fork_and_edit_path(@project, @ref, @path) + - else + = link_to '#modal-confirm-fork', class: 'btn btn-default qa-web-ide-button', data: { target: '#modal-confirm-fork', toggle: 'modal'} do + = _('Web IDE') + = render 'shared/confirm_fork_modal', fork_path: ide_fork_and_edit_path(@project, @ref, @path) - if show_xcode_link?(@project) - .project-action-button.project-xcode.inline + .project-action-button.project-xcode.inline< = render "projects/buttons/xcode_link" = render 'projects/buttons/download', project: @project, ref: @ref diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml index 84198489e41..255a62d0d06 100644 --- a/app/views/search/_category.html.haml +++ b/app/views/search/_category.html.haml @@ -27,7 +27,7 @@ = search_filter_link 'snippet_blobs', _("Snippet Contents"), search: { snippets: true, group_id: nil, project_id: nil } = search_filter_link 'snippet_titles', _("Titles and Filenames"), search: { snippets: true, group_id: nil, project_id: nil } - else - = search_filter_link 'projects', _("Projects") + = search_filter_link 'projects', _("Projects"), data: { qa_selector: 'projects_tab' } = search_filter_link 'issues', _("Issues") = search_filter_link 'merge_requests', _("Merge requests") = search_filter_link 'milestones', _("Milestones") diff --git a/app/views/search/results/_snippet_blob.html.haml b/app/views/search/results/_snippet_blob.html.haml index 37f4efee9d2..0b114bf67ee 100644 --- a/app/views/search/results/_snippet_blob.html.haml +++ b/app/views/search/results/_snippet_blob.html.haml @@ -1,7 +1,7 @@ - snippet_blob = chunk_snippet(snippet_blob, @search_term) - snippet = snippet_blob[:snippet_object] - snippet_chunks = snippet_blob[:snippet_chunks] -- snippet_path = reliable_snippet_path(snippet) +- snippet_path = gitlab_snippet_path(snippet) .search-result-row %span diff --git a/app/views/search/results/_snippet_title.html.haml b/app/views/search/results/_snippet_title.html.haml index 7280146720e..81e746c55a3 100644 --- a/app/views/search/results/_snippet_title.html.haml +++ b/app/views/search/results/_snippet_title.html.haml @@ -1,6 +1,6 @@ .search-result-row %h4.snippet-title.term - = link_to reliable_snippet_path(snippet_title) do + = link_to gitlab_snippet_path(snippet_title) do = truncate(snippet_title.title, length: 60) = snippet_badge(snippet_title) %span.cgray.monospace.tiny.float-right.term diff --git a/app/views/shared/_personal_access_tokens_form.html.haml b/app/views/shared/_personal_access_tokens_form.html.haml index ca0b473addf..16f8a692635 100644 --- a/app/views/shared/_personal_access_tokens_form.html.haml +++ b/app/views/shared/_personal_access_tokens_form.html.haml @@ -18,6 +18,9 @@ .form-group.col-md-6 = f.label :expires_at, _('Expires at'), class: 'label-bold' .input-icon-wrapper + + = render_if_exists 'personal_access_tokens/callout_max_personal_access_token_lifetime' + = f.text_field :expires_at, class: "datepicker form-control", placeholder: 'YYYY-MM-DD' .form-group diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml index 627a1eb6eae..1bf52feab11 100644 --- a/app/views/shared/_service_settings.html.haml +++ b/app/views/shared/_service_settings.html.haml @@ -16,7 +16,7 @@ - if @service.configurable_events.present? .form-group.row - .col-sm-2.text-right Trigger + %label.col-form-label.col-sm-2= _('Trigger') .col-sm-10 - @service.configurable_events.each do |event| @@ -35,6 +35,22 @@ %p.text-muted = @service.class.event_description(event) + - if @service.configurable_event_actions.present? + .form-group.row + %label.col-form-label.col-sm-2= _('Event Actions') + + .col-sm-10 + - @service.configurable_event_actions.each do |action| + .form-group + .form-check + = form.check_box service_event_action_field_name(action), class: 'form-check-input' + = form.label service_event_action_field_name(action), class: 'form-check-label' do + %strong + = event_action_description(action) + + %p.text-muted + = event_action_description(action) + - @service.global_fields.each do |field| - type = field[:type] diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml index 93fc839a371..7f62b983bfc 100644 --- a/app/views/shared/boards/_show.html.haml +++ b/app/views/shared/boards/_show.html.haml @@ -29,6 +29,7 @@ ":board-id" => "boardId", ":key" => "list.id" } = render "shared/boards/components/sidebar", group: group + = render_if_exists 'shared/boards/components/board_settings_sidebar' - if @project %board-add-issues-modal{ "new-issue-path" => new_project_issue_path(@project), "milestone-path" => milestones_filter_dropdown_path, diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml index ffa24d1c041..eb9b7f6c48a 100644 --- a/app/views/shared/boards/components/_board.html.haml +++ b/app/views/shared/boards/components/_board.html.haml @@ -42,23 +42,27 @@ %button.board-delete.no-drag.p-0.border-0.has-tooltip.float-right{ type: "button", title: _("Delete list"), ":class": "{ 'd-none': !list.isExpanded }", "aria-label" => _("Delete list"), data: { placement: "bottom" }, "@click.stop" => "deleteBoard" } = icon("trash") - .issue-count-badge.no-drag.text-secondary{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"', ":title": "counterTooltip", "v-tooltip": true, data: { placement: "top" } } + .issue-count-badge.pr-0.no-drag.text-secondary{ "v-if" => "showBoardListAndBoardInfo", ":title": "counterTooltip", "v-tooltip": true, data: { placement: "top" } } %span.d-inline-flex %span.issue-count-badge-count %icon.mr-1{ name: "issues" } - {{ list.issuesSize }} + %issue-count{ ":maxIssueCount" => "list.maxIssueCount", + ":issuesSize" => "list.issuesSize" } = render_if_exists "shared/boards/components/list_weight" - %button.issue-count-badge-add-button.no-drag.btn.btn-sm.btn-default.ml-1.has-tooltip{ type: "button", - "@click" => "showNewIssueForm", - "v-if" => "isNewIssueShown", - ":class": "{ 'd-none': !list.isExpanded }", - "aria-label" => _("New issue"), - "title" => _("New issue"), - data: { placement: "top", container: "body" } } - = icon("plus") + %gl-button-group.board-list-button-group.pl-2{ "v-if" => "isNewIssueShown || isSettingsShown" } + %gl-button.issue-count-badge-add-button.no-drag{ type: "button", + "@click" => "showNewIssueForm", + "v-if" => "isNewIssueShown", + ":class": "{ 'd-none': !list.isExpanded, 'rounded-right': isNewIssueShown && !isSettingsShown }", + "aria-label" => _("New issue"), + "ref" => "newIssueBtn" } + = icon("plus") + %gl-tooltip{ ":target" => "() => $refs.newIssueBtn" } + = _("New Issue") + = render_if_exists 'shared/boards/components/list_settings' - %board-list{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"', + %board-list{ "v-if" => "showBoardListAndBoardInfo", ":list" => "list", ":issues" => "list.issues", ":loading" => "list.loading", diff --git a/app/views/shared/buttons/_project_feature_toggle.html.haml b/app/views/shared/buttons/_project_feature_toggle.html.haml new file mode 100644 index 00000000000..0f630786455 --- /dev/null +++ b/app/views/shared/buttons/_project_feature_toggle.html.haml @@ -0,0 +1,16 @@ +- class_list ||= "js-project-feature-toggle project-feature-toggle" +- data ||= nil +- disabled ||= false +- is_checked ||= false +- label ||= nil + +%button{ type: 'button', + class: "#{class_list} #{'is-disabled' if disabled} #{'is-checked' if is_checked}", + "aria-label": label, + disabled: disabled, + data: data } + - if yield.present? + = yield + %span.toggle-icon + = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked') + = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked') diff --git a/app/views/shared/empty_states/_snippets.html.haml b/app/views/shared/empty_states/_snippets.html.haml index a1a16b9d067..889a470d6ec 100644 --- a/app/views/shared/empty_states/_snippets.html.haml +++ b/app/views/shared/empty_states/_snippets.html.haml @@ -11,7 +11,8 @@ %p = s_('SnippetsEmptyState|They can be either public or private.') .text-center - = link_to s_('SnippetsEmptyState|New snippet'), button_path, class: 'btn btn-success', title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link' + - if button_path + = link_to s_('SnippetsEmptyState|New snippet'), button_path, class: 'btn btn-success', title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link' - unless current_page?(dashboard_snippets_path) = link_to s_('SnippetsEmptyState|Explore public snippets'), explore_snippets_path, class: 'btn btn-default', title: s_('SnippetsEmptyState|Explore public snippets') - else diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml index 609b8dce21a..e47967ef622 100644 --- a/app/views/shared/groups/_group.html.haml +++ b/app/views/shared/groups/_group.html.haml @@ -1,7 +1,7 @@ - user = local_assigns.fetch(:user, current_user) - access = user&.max_member_access_for_group(group.id) -%li.group-row{ class: ('no-description' if group.description.blank?) } +%li.group-row.py-3{ class: ('no-description' if group.description.blank?) } .stats %span = icon('bookmark') diff --git a/app/views/shared/icons/_convdev_no_data.svg b/app/views/shared/icons/_dev_ops_score_no_data.svg index ed32b2333e7..ed32b2333e7 100644 --- a/app/views/shared/icons/_convdev_no_data.svg +++ b/app/views/shared/icons/_dev_ops_score_no_data.svg diff --git a/app/views/shared/icons/_convdev_no_index.svg b/app/views/shared/icons/_dev_ops_score_no_index.svg index 95c00e81d10..95c00e81d10 100644 --- a/app/views/shared/icons/_convdev_no_index.svg +++ b/app/views/shared/icons/_dev_ops_score_no_index.svg diff --git a/app/views/shared/icons/_convdev_overview.svg b/app/views/shared/icons/_dev_ops_score_overview.svg index 2f31113bad7..2f31113bad7 100644 --- a/app/views/shared/icons/_convdev_overview.svg +++ b/app/views/shared/icons/_dev_ops_score_overview.svg diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml index 875cacd1f4f..2eb96a7bc9b 100644 --- a/app/views/shared/issuable/_close_reopen_button.html.haml +++ b/app/views/shared/issuable/_close_reopen_button.html.haml @@ -6,7 +6,7 @@ - if is_current_user - if can_update = link_to "Close #{display_issuable_type}", close_issuable_path(issuable), method: button_method, - class: "d-none d-sm-none d-md-block btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}" + class: "d-none d-sm-none d-md-block btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}", data: { qa_selector: 'close_issue_button' } - if can_reopen = link_to "Reopen #{display_issuable_type}", reopen_issuable_path(issuable), method: button_method, class: "d-none d-sm-none d-md-block btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}", title: "Reopen #{display_issuable_type}", data: { qa_selector: 'reopen_issue_button' } diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index d341520e4a2..5da86195243 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -6,7 +6,7 @@ .issues-filters{ class: ("w-100" if type == :boards_modal) } .issues-details-filters.filtered-search-block.d-flex.flex-column.flex-lg-row{ class: block_css_class, "v-pre" => type == :boards_modal } - .d-flex.flex-column.flex-md-row.flex-grow-1.mb-lg-0.mb-md-2.mb-sm-0 + .d-flex.flex-column.flex-md-row.flex-grow-1.mb-lg-0.mb-md-2.mb-sm-0.w-100 - if type == :boards = render "shared/boards/switcher", board: board = form_tag page_filter_path, method: :get, class: 'filter-form js-filter-form w-100' do @@ -162,8 +162,8 @@ %button.clear-search.hidden{ type: 'button' } = icon('times') .filter-dropdown-container.d-flex.flex-column.flex-md-row - #js-board-labels-toggle - if type == :boards + #js-board-labels-toggle .js-board-config{ data: { can_admin_list: user_can_admin_list, has_scope: board.scoped? } } - if user_can_admin_list = render 'shared/issuable/board_create_list_dropdown', board: board diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 2170b88c7c3..2a853de12a4 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -30,7 +30,7 @@ = render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar - milestone = issuable_sidebar[:milestone] || {} - .block.milestone + .block.milestone{ data: { qa_selector: 'milestone_block' } } .sidebar-collapsed-icon.has-tooltip{ title: sidebar_milestone_tooltip_label(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } } = icon('clock-o', 'aria-hidden': 'true') %span.milestone-title.collapse-truncated-title @@ -45,7 +45,7 @@ = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { track_label: "right_sidebar", track_property: "milestone", track_event: "click_edit_button", track_value: "" } .value.hide-collapsed - if milestone.present? - = link_to milestone[:title], milestone[:web_url], class: "bold has-tooltip", title: sidebar_milestone_remaining_days(milestone), data: { container: "body", html: 'true', boundary: 'viewport', qa_selector: 'milestone_link' } + = link_to milestone[:title], milestone[:web_url], class: "bold has-tooltip", title: sidebar_milestone_remaining_days(milestone), data: { container: "body", html: 'true', boundary: 'viewport', qa_selector: 'milestone_link', qa_title: milestone[:title] } - else %span.no-value = _('None') @@ -107,10 +107,10 @@ = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link qa-edit-link-labels float-right', data: { track_label: "right_sidebar", track_property: "labels", track_event: "click_edit_button", track_value: "" } - .value.issuable-show-labels.dont-hide.hide-collapsed.qa-labels-block{ class: ("has-labels" if selected_labels.any?) } + .value.issuable-show-labels.dont-hide.hide-collapsed{ class: ("has-labels" if selected_labels.any?), data: { qa_selector: 'labels_block' } } - if selected_labels.any? - selected_labels.each do |label_hash| - = render_label(label_from_hash(label_hash).present(issuable_subject: nil), link: sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label_hash[:title])) + = render_label(label_from_hash(label_hash).present(issuable_subject: nil), link: sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label_hash[:title]), dataset: { qa_selector: 'label', qa_label_name: label_hash[:title] }) - else %span.no-value = _('None') diff --git a/app/views/shared/labels/_nav.html.haml b/app/views/shared/labels/_nav.html.haml index e69246dd0eb..d613ea466fa 100644 --- a/app/views/shared/labels/_nav.html.haml +++ b/app/views/shared/labels/_nav.html.haml @@ -13,7 +13,7 @@ = form_tag labels_filter_path, method: :get do = hidden_field_tag :subscribed, params[:subscribed] .input-group - = search_field_tag :search, params[:search], { placeholder: _('Filter'), id: 'label-search', class: 'form-control search-text-input input-short', spellcheck: false } + = search_field_tag :search, params[:search], { placeholder: _('Filter'), id: 'label-search', class: 'form-control search-text-input input-short', spellcheck: false, autofocus: true } %span.input-group-append %button.btn.btn-default{ type: "submit", "aria-label" => _('Submit search') } = icon("search") diff --git a/app/views/shared/members/_sort_dropdown.html.haml b/app/views/shared/members/_sort_dropdown.html.haml index 5f3d49adff7..50a55565c3c 100644 --- a/app/views/shared/members/_sort_dropdown.html.haml +++ b/app/views/shared/members/_sort_dropdown.html.haml @@ -8,3 +8,13 @@ %li = link_to filter_group_project_member_path(sort: value), class: ("is-active" if @sort == value) do = title + %li.divider + %li{ data: { 'qa-selector': 'filter-members-with-inherited-permissions' } } + = link_to filter_group_project_member_path(with_inherited_permissions: nil), class: ("is-active" unless params[:with_inherited_permissions].present?) do + = _("Show all members") + %li{ data: { 'qa-selector': 'filter-members-with-inherited-permissions' } } + = link_to filter_group_project_member_path(with_inherited_permissions: 'exclude'), class: ("is-active" if params[:with_inherited_permissions] == 'exclude') do + = _("Show only direct members") + %li{ data: { 'qa-selector': 'filter-members-with-inherited-permissions' } } + = link_to filter_group_project_member_path(with_inherited_permissions: 'only'), class: ("is-active" if params[:with_inherited_permissions] == 'only') do + = _("Show only inherited members") diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index b324f35c338..6e50b31fd71 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -43,8 +43,9 @@ .col-sm-4.milestone-progress = milestone_progress_bar(milestone) = link_to pluralize(milestone.total_issues_count(current_user), 'Issue'), issues_path - · - = link_to pluralize(milestone.merge_requests_visible_to_user(current_user).size, 'Merge Request'), merge_requests_path + - if milestone.merge_requests_enabled? + · + = link_to pluralize(milestone.merge_requests_visible_to_user(current_user).size, 'Merge Request'), merge_requests_path .float-lg-right.light #{milestone.percent_complete(current_user)}% complete .col-sm-2 .milestone-actions.d-flex.justify-content-sm-start.justify-content-md-end diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index b6656e6283c..fbbcc4f3e68 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -105,38 +105,39 @@ = render_if_exists 'shared/milestones/weight', milestone: milestone - .block.merge-requests - .sidebar-collapsed-icon.has-tooltip{ title: milestone_merge_requests_tooltip_text(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } } - %strong - = custom_icon('mr_bold') - %span= milestone.merge_requests.count - .title.hide-collapsed - Merge requests - %span.badge.badge-pill= milestone.merge_requests.count - .value.hide-collapsed.bold - - if !project || can?(current_user, :read_merge_request, project) - %span.milestone-stat - = link_to milestones_browse_issuables_path(milestone, type: :merge_requests) do + - if milestone.merge_requests_enabled? + .block.merge-requests + .sidebar-collapsed-icon.has-tooltip{ title: milestone_merge_requests_tooltip_text(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } } + %strong + = custom_icon('mr_bold') + %span= milestone.merge_requests.count + .title.hide-collapsed + Merge requests + %span.badge.badge-pill= milestone.merge_requests.count + .value.hide-collapsed.bold + - if !project || can?(current_user, :read_merge_request, project) + %span.milestone-stat + = link_to milestones_browse_issuables_path(milestone, type: :merge_requests) do + Open: + = milestone.merge_requests.opened.count + %span.milestone-stat + = link_to milestones_browse_issuables_path(milestone, type: :merge_requests, state: 'closed') do + Closed: + = milestone.merge_requests.closed.count + %span.milestone-stat + = link_to milestones_browse_issuables_path(milestone, type: :merge_requests, state: 'merged') do + Merged: + = milestone.merge_requests.merged.count + - else + %span.milestone-stat Open: = milestone.merge_requests.opened.count - %span.milestone-stat - = link_to milestones_browse_issuables_path(milestone, type: :merge_requests, state: 'closed') do + %span.milestone-stat Closed: = milestone.merge_requests.closed.count - %span.milestone-stat - = link_to milestones_browse_issuables_path(milestone, type: :merge_requests, state: 'merged') do + %span.milestone-stat Merged: = milestone.merge_requests.merged.count - - else - %span.milestone-stat - Open: - = milestone.merge_requests.opened.count - %span.milestone-stat - Closed: - = milestone.merge_requests.closed.count - %span.milestone-stat - Merged: - = milestone.merge_requests.merged.count - if project - recent_releases, total_count, more_count = recent_releases_with_counts(milestone) diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml index f718c5767d1..538ebe79641 100644 --- a/app/views/shared/milestones/_tabs.html.haml +++ b/app/views/shared/milestones/_tabs.html.haml @@ -6,10 +6,11 @@ = link_to '#tab-issues', class: 'nav-link active', data: { toggle: 'tab', show: '.tab-issues-buttons' } do = _('Issues') %span.badge.badge-pill= milestone.issues_visible_to_user(current_user).size - %li.nav-item - = link_to '#tab-merge-requests', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'merge_requests') } do - = _('Merge Requests') - %span.badge.badge-pill= milestone.merge_requests_visible_to_user(current_user).size + - if milestone.merge_requests_enabled? + %li.nav-item + = link_to '#tab-merge-requests', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'merge_requests') } do + = _('Merge Requests') + %span.badge.badge-pill= milestone.merge_requests_visible_to_user(current_user).size %li.nav-item = link_to '#tab-participants', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'participants') } do = _('Participants') @@ -26,9 +27,10 @@ .tab-content.milestone-content .tab-pane.active#tab-issues{ data: { sort_endpoint: (sort_issues_project_milestone_path(@project, @milestone) if @project && current_user) } } = render 'shared/milestones/issues_tab', issues: issues, show_project_name: show_project_name, show_full_project_name: show_full_project_name - .tab-pane#tab-merge-requests - -# loaded async - = render "shared/milestones/tab_loading" + - if milestone.merge_requests_enabled? + .tab-pane#tab-merge-requests + -# loaded async + = render "shared/milestones/tab_loading" .tab-pane#tab-participants -# loaded async = render "shared/milestones/tab_loading" diff --git a/app/views/shared/notifications/_custom_notifications.html.haml b/app/views/shared/notifications/_custom_notifications.html.haml index 1fef43c0c37..be574155436 100644 --- a/app/views/shared/notifications/_custom_notifications.html.haml +++ b/app/views/shared/notifications/_custom_notifications.html.haml @@ -18,7 +18,7 @@ .col-lg-4 %h4.prepend-top-0= _('Notification events') %p - - notification_link = link_to _('notification emails'), help_page_path('workflow/notifications'), target: '_blank' + - notification_link = link_to _('notification emails'), help_page_path('user/profile/notifications'), target: '_blank' - paragraph = _('Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}.') % { notification_link: notification_link.html_safe } #{ paragraph.html_safe } .col-lg-8 diff --git a/app/views/shared/projects/_archived.html.haml b/app/views/shared/projects/_archived.html.haml new file mode 100644 index 00000000000..fad93d14390 --- /dev/null +++ b/app/views/shared/projects/_archived.html.haml @@ -0,0 +1,3 @@ +- if project.archived + %span.d-flex.badge.badge-warning + = _('archived') diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml index 59b4facdbe5..fab7ee9d763 100644 --- a/app/views/shared/projects/_list.html.haml +++ b/app/views/shared/projects/_list.html.haml @@ -35,6 +35,7 @@ .js-projects-list-holder{ data: { qa_selector: 'projects_list' } } - if any_projects?(projects) - load_pipeline_status(projects) if pipeline_status + - load_max_project_member_accesses(projects) # Prime cache used in shared/projects/project view rendered below %ul.projects-list{ class: css_classes } - projects.each_with_index do |project, i| - css_class = (i >= projects_limit) || project.pending_delete? ? 'hide' : nil diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 67dad9b7a75..45e95685677 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -26,7 +26,7 @@ = image_tag avatar_icon_for_user(project.creator, 48), class: "avatar s48", alt:'' - else = project_icon(project, alt: '', class: 'avatar project-avatar s48', width: 48, height: 48) - .project-details.d-sm-flex.flex-sm-fill.align-items-center + .project-details.d-sm-flex.flex-sm-fill.align-items-center{ data: { qa_selector: 'project', qa_project_name: project.name } } .flex-wrapper .d-flex.align-items-center.flex-wrap.project-title %h2.d-flex.prepend-top-8 @@ -67,8 +67,7 @@ %span.icon-wrapper.pipeline-status = render 'ci/status/icon', status: project.last_pipeline.detailed_status(current_user), tooltip_placement: 'top', path: pipeline_path - - if project.archived - %span.d-flex.icon-wrapper.badge.badge-warning archived + = render_if_exists 'shared/projects/archived', project: project - if stars = link_to project_starrers_path(project), class: "d-flex align-items-center icon-wrapper stars has-tooltip", diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml index 67f177288f0..1243bdab6dd 100644 --- a/app/views/shared/snippets/_header.html.haml +++ b/app/views/shared/snippets/_header.html.haml @@ -17,11 +17,11 @@ = render "snippets/actions" .snippet-header.limited-header-width - %h2.snippet-title.prepend-top-0.mb-3.qa-snippet-title + %h2.snippet-title.prepend-top-0.mb-3{ data: { qa_selector: 'snippet_title' } } = markdown_field(@snippet, :title) - if @snippet.description.present? - .description.qa-snippet-description + .description{ data: { qa_selector: 'snippet_description' } } .md = markdown_field(@snippet, :description) %textarea.hidden.js-task-list-field @@ -44,7 +44,7 @@ %li %button.js-share-btn.btn.btn-transparent{ type: 'button' } %strong.embed-toggle-list-item= _("Share") - %input.js-snippet-url-area.snippet-embed-input.form-control{ type: "text", autocomplete: 'off', value: snippet_embed_tag(@snippet) } + = snippet_embed_input(@snippet) .input-group-append = clipboard_button(title: _('Copy'), class: 'js-clipboard-btn snippet-clipboard-btn btn btn-default', target: '.js-snippet-url-area') .clearfix diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml index 5602ea37b5c..9e038854c59 100644 --- a/app/views/shared/snippets/_snippet.html.haml +++ b/app/views/shared/snippets/_snippet.html.haml @@ -1,11 +1,11 @@ - link_project = local_assigns.fetch(:link_project, false) - notes_count = @noteable_meta_data[snippet.id].user_notes_count -%li.snippet-row +%li.snippet-row.py-3 = image_tag avatar_icon_for_user(snippet.author), class: "avatar s40 d-none d-sm-block", alt: '' .title - = link_to reliable_snippet_path(snippet) do + = link_to gitlab_snippet_path(snippet) do = snippet.title - if snippet.file_name.present? %span.snippet-filename.d-none.d-sm-inline-block.ml-2 @@ -14,7 +14,7 @@ %ul.controls %li - = link_to reliable_snippet_path(snippet, anchor: 'notes'), class: ('no-comments' if notes_count.zero?) do + = link_to gitlab_snippet_path(snippet, anchor: 'notes'), class: ('no-comments' if notes_count.zero?) do = icon('comments') = notes_count %li diff --git a/app/views/shared/tokens/_scopes_list.html.haml b/app/views/shared/tokens/_scopes_list.html.haml index 428861485b4..913392be510 100644 --- a/app/views/shared/tokens/_scopes_list.html.haml +++ b/app/views/shared/tokens/_scopes_list.html.haml @@ -9,5 +9,5 @@ %ul.scopes-list.append-bottom-0 - token.scopes.each do |scope| %li - %span.scope-name= scope + %span.bold= scope = "(#{t(scope, scope: [:doorkeeper, :scopes])})" diff --git a/app/views/shared/web_hooks/_hook.html.haml b/app/views/shared/web_hooks/_hook.html.haml new file mode 100644 index 00000000000..34a62340966 --- /dev/null +++ b/app/views/shared/web_hooks/_hook.html.haml @@ -0,0 +1,16 @@ +%li + .row + .col-md-8.col-lg-7 + %strong.light-header= hook.url + %div + - hook.class.triggers.each_value do |trigger| + - if hook.public_send(trigger) + %span.badge.badge-gray.deploy-project-label= trigger.to_s.titleize + %span.badge.badge-gray + = _('SSL Verification:') + = hook.enable_ssl_verification ? _('enabled') : _('disabled') + + .col-md-4.col-lg-5.text-right-md.prepend-top-5 + %span>= render 'shared/web_hooks/test_button', hook: hook, button_class: 'btn-sm append-right-8' + %span>= link_to _('Edit'), edit_hook_path(hook), class: 'btn btn-sm append-right-8' + = link_to _('Delete'), destroy_hook_path(hook), data: { confirm: _('Are you sure?') }, method: :delete, class: 'btn btn-sm' diff --git a/app/views/shared/web_hooks/_index.html.haml b/app/views/shared/web_hooks/_index.html.haml new file mode 100644 index 00000000000..b22d51a101a --- /dev/null +++ b/app/views/shared/web_hooks/_index.html.haml @@ -0,0 +1,14 @@ +%hr +.card + .card-header + %h5 + = hook_class.underscore.humanize.titleize.pluralize + (#{hooks.count}) + + - if hooks.any? + %ul.content-list + - hooks.each do |hook| + = render 'shared/web_hooks/hook', hook: hook + - else + %p.text-center.prepend-top-default.append-bottom-default + = _('No webhooks found, add one in the form above.') diff --git a/app/views/shared/web_hooks/_test_button.html.haml b/app/views/shared/web_hooks/_test_button.html.haml index 5ece8b1d4c7..fc24e425ab6 100644 --- a/app/views/shared/web_hooks/_test_button.html.haml +++ b/app/views/shared/web_hooks/_test_button.html.haml @@ -1,10 +1,10 @@ -- triggers = local_assigns.fetch(:triggers) - button_class = local_assigns.fetch(:button_class, '') - hook = local_assigns.fetch(:hook) +- triggers = hook.class.triggers -.hook-test-button.dropdown.inline +.hook-test-button.dropdown.inline> %button.btn{ 'data-toggle' => 'dropdown', class: button_class } - Test + = _('Test') = icon('caret-down') %ul.dropdown-menu.dropdown-menu-right{ role: 'menu' } - triggers.each_value do |event| diff --git a/app/views/shared/web_hooks/_title_and_docs.html.haml b/app/views/shared/web_hooks/_title_and_docs.html.haml new file mode 100644 index 00000000000..359f5f34f5b --- /dev/null +++ b/app/views/shared/web_hooks/_title_and_docs.html.haml @@ -0,0 +1,5 @@ +%h4.prepend-top-0 + = page_title +%p + - link = link_to(hook.pluralized_name, help_page_path(hook.help_path)) + = _('%{link} can be used for binding events when something is happening within the project.').html_safe % { link: link } diff --git a/app/views/snippets/_actions.html.haml b/app/views/snippets/_actions.html.haml index 9952f373156..5ee12a2f22a 100644 --- a/app/views/snippets/_actions.html.haml +++ b/app/views/snippets/_actions.html.haml @@ -5,10 +5,11 @@ = link_to edit_snippet_path(@snippet), class: "btn btn-grouped" do = _("Edit") - if can?(current_user, :admin_personal_snippet, @snippet) - = link_to snippet_path(@snippet), method: :delete, data: { confirm: _("Are you sure?") }, class: "btn btn-grouped btn-inverted btn-remove", title: _('Delete Snippet') do + = link_to gitlab_snippet_path(@snippet), method: :delete, data: { confirm: _("Are you sure?") }, class: "btn btn-grouped btn-inverted btn-remove", title: _('Delete Snippet') do = _("Delete") - = link_to new_snippet_path, class: "btn btn-grouped btn-success btn-inverted", title: _("New snippet") do - = _("New snippet") + - if can?(current_user, :create_personal_snippet) + = link_to new_snippet_path, class: "btn btn-grouped btn-success btn-inverted", title: _("New snippet") do + = _("New snippet") - if @snippet.submittable_as_spam_by?(current_user) = link_to _('Submit as spam'), mark_as_spam_snippet_path(@snippet), method: :post, class: 'btn btn-grouped btn-spam', title: _('Submit as spam') .d-block.d-sm-none.dropdown @@ -17,12 +18,13 @@ = icon('caret-down') .dropdown-menu.dropdown-menu-full-width %ul - %li - = link_to new_snippet_path, title: _("New snippet") do - = _("New snippet") + - if can?(current_user, :create_personal_snippet) + %li + = link_to new_snippet_path, title: _("New snippet") do + = _("New snippet") - if can?(current_user, :admin_personal_snippet, @snippet) %li - = link_to snippet_path(@snippet), method: :delete, data: { confirm: _("Are you sure?") }, title: _('Delete Snippet') do + = link_to gitlab_snippet_path(@snippet), method: :delete, data: { confirm: _("Are you sure?") }, title: _('Delete Snippet') do = _("Delete") - if can?(current_user, :update_personal_snippet, @snippet) %li diff --git a/app/views/snippets/_snippets.html.haml b/app/views/snippets/_snippets.html.haml index dab247da251..69b19c0def9 100644 --- a/app/views/snippets/_snippets.html.haml +++ b/app/views/snippets/_snippets.html.haml @@ -3,7 +3,7 @@ - current_user_empty_message_header = s_('UserProfile|You haven\'t created any snippets.') - current_user_empty_message_description = s_('UserProfile|Snippets in GitLab can either be private, internal, or public.') - primary_button_label = _('New snippet') -- primary_button_link = new_snippet_path +- primary_button_link = new_snippet_path if can?(current_user, :create_personal_snippet) - visitor_empty_message = s_('UserProfile|No snippets found.') .snippets-list-holder diff --git a/app/views/snippets/_snippets_scope_menu.html.haml b/app/views/snippets/_snippets_scope_menu.html.haml index c312226dd6c..cb59b11ca2b 100644 --- a/app/views/snippets/_snippets_scope_menu.html.haml +++ b/app/views/snippets/_snippets_scope_menu.html.haml @@ -9,7 +9,7 @@ - if include_private = subject.snippets.count - else - = subject.snippets.public_and_internal.count + = subject.snippets.public_and_internal_only.count - if include_private %li{ class: active_when(params[:scope] == "are_private") } diff --git a/app/views/snippets/edit.html.haml b/app/views/snippets/edit.html.haml index ebc6c0a2605..f5ffb037152 100644 --- a/app/views/snippets/edit.html.haml +++ b/app/views/snippets/edit.html.haml @@ -3,4 +3,4 @@ %h3.page-title = _("Edit Snippet") %hr -= render 'shared/snippets/form', url: snippet_path(@snippet) += render 'shared/snippets/form', url: gitlab_snippet_path(@snippet) diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml index 36b4e00e8d5..080c0ab6ece 100644 --- a/app/views/snippets/show.html.haml +++ b/app/views/snippets/show.html.haml @@ -4,13 +4,16 @@ - breadcrumb_title @snippet.to_reference - page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets") -= render 'shared/snippets/header' +- if Feature.enabled?(:snippets_vue) + #js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id} } +- else + = render 'shared/snippets/header' -.personal-snippets - %article.file-holder.snippet-file-content - = render 'shared/snippets/blob' + .personal-snippets + %article.file-holder.snippet-file-content + = render 'shared/snippets/blob' - .row-content-block.top-block.content-component-block - = render 'award_emoji/awards_block', awardable: @snippet, inline: true + .row-content-block.top-block.content-component-block + = render 'award_emoji/awards_block', awardable: @snippet, inline: true - #notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => false + #notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => false diff --git a/app/views/users/_profile_basic_info.html.haml b/app/views/users/_profile_basic_info.html.haml new file mode 100644 index 00000000000..af0a766bab0 --- /dev/null +++ b/app/views/users/_profile_basic_info.html.haml @@ -0,0 +1,6 @@ +%p + %span.middle-dot-divider + @#{@user.username} + - if can?(current_user, :read_user_profile, @user) + %span.middle-dot-divider + = s_('Member since %{date}') % { date: @user.created_at.to_date.to_s(:long) } diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index e1c75d5d0f4..e10dad8aa8d 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -1,7 +1,7 @@ - @hide_top_links = true - @hide_breadcrumbs = true - @no_container = true -- page_title @user.name +- page_title @user.blocked? ? s_('UserProfile|Blocked user') : @user.name - page_description @user.bio - header_title @user.name, user_path(@user) @@ -36,50 +36,48 @@ = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do = image_tag avatar_icon_for_user(@user, 90), class: "avatar s90", alt: '' - .user-info - .cover-title - = @user.name - - - if @user.status - .cover-status - = emoji_icon(@user.status.emoji) - = markdown_field(@user.status, :message) - - .cover-desc.member-date.cgray - %p - %span.middle-dot-divider - @#{@user.username} - - if can?(current_user, :read_user_profile, @user) - %span.middle-dot-divider - = s_('Member since %{date}') % { date: @user.created_at.to_date.to_s(:long) } - - .cover-desc.cgray - - unless @user.public_email.blank? - .profile-link-holder.middle-dot-divider - = link_to @user.public_email, "mailto:#{@user.public_email}", class: 'text-link' - - unless @user.skype.blank? - .profile-link-holder.middle-dot-divider - = link_to "skype:#{@user.skype}", title: "Skype" do - = icon('skype') - - unless @user.linkedin.blank? - .profile-link-holder.middle-dot-divider - = link_to linkedin_url(@user), title: "LinkedIn", target: '_blank', rel: 'noopener noreferrer nofollow' do - = icon('linkedin-square') - - unless @user.twitter.blank? - .profile-link-holder.middle-dot-divider - = link_to twitter_url(@user), title: "Twitter", target: '_blank', rel: 'noopener noreferrer nofollow' do - = icon('twitter-square') - - unless @user.website_url.blank? - .profile-link-holder.middle-dot-divider - = link_to @user.short_website_url, @user.full_website_url, class: 'text-link', target: '_blank', rel: 'me noopener noreferrer nofollow' - - unless @user.location.blank? - .profile-link-holder.middle-dot-divider - = sprite_icon('location', size: 16, css_class: 'vertical-align-sub') - = @user.location - - unless @user.organization.blank? - .profile-link-holder.middle-dot-divider - = sprite_icon('work', size: 16, css_class: 'vertical-align-sub') - = @user.organization + - if @user.blocked? + .user-info + .cover-title + = s_('UserProfile|Blocked user') + = render "users/profile_basic_info" + - else + .user-info + .cover-title + = @user.name + + - if @user.status + .cover-status + = emoji_icon(@user.status.emoji) + = markdown_field(@user.status, :message) + = render "users/profile_basic_info" + .cover-desc.cgray + - unless @user.public_email.blank? + .profile-link-holder.middle-dot-divider + = link_to @user.public_email, "mailto:#{@user.public_email}", class: 'text-link' + - unless @user.skype.blank? + .profile-link-holder.middle-dot-divider + = link_to "skype:#{@user.skype}", title: "Skype" do + = icon('skype') + - unless @user.linkedin.blank? + .profile-link-holder.middle-dot-divider + = link_to linkedin_url(@user), title: "LinkedIn", target: '_blank', rel: 'noopener noreferrer nofollow' do + = icon('linkedin-square') + - unless @user.twitter.blank? + .profile-link-holder.middle-dot-divider + = link_to twitter_url(@user), title: "Twitter", target: '_blank', rel: 'noopener noreferrer nofollow' do + = icon('twitter-square') + - unless @user.website_url.blank? + .profile-link-holder.middle-dot-divider + = link_to @user.short_website_url, @user.full_website_url, class: 'text-link', target: '_blank', rel: 'me noopener noreferrer nofollow' + - unless @user.location.blank? + .profile-link-holder.middle-dot-divider + = sprite_icon('location', size: 16, css_class: 'vertical-align-sub') + = @user.location + - unless @user.organization.blank? + .profile-link-holder.middle-dot-divider + = sprite_icon('work', size: 16, css_class: 'vertical-align-sub') + = @user.organization - if @user.bio.present? .cover-desc.cgray @@ -165,4 +163,8 @@ .col-12.text-center .text-content %h4 - = s_('UserProfile|This user has a private profile') + - if @user.blocked? + = s_('UserProfile|This user is blocked') + - else + = s_('UserProfile|This user has a private profile') + diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 66b5214cfcb..02acf360afc 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -16,6 +16,7 @@ - cronjob:pages_domain_verification_cron - cronjob:pages_domain_removal_cron - cronjob:pages_domain_ssl_renewal_cron +- cronjob:personal_access_tokens_expiring - cronjob:pipeline_schedule - cronjob:prune_old_events - cronjob:remove_expired_group_links @@ -38,6 +39,9 @@ - gcp_cluster:cluster_patch_app - gcp_cluster:cluster_upgrade_app - gcp_cluster:cluster_provision +- gcp_cluster:clusters_cleanup_app +- gcp_cluster:clusters_cleanup_project_namespace +- gcp_cluster:clusters_cleanup_service_account - gcp_cluster:cluster_wait_for_app_installation - gcp_cluster:wait_for_cluster_creation - gcp_cluster:cluster_wait_for_ingress_ip_address @@ -48,6 +52,8 @@ - gcp_cluster:clusters_cleanup_app - gcp_cluster:clusters_cleanup_project_namespace - gcp_cluster:clusters_cleanup_service_account +- gcp_cluster:clusters_applications_activate_service +- gcp_cluster:clusters_applications_deactivate_service - github_import_advance_stage - github_importer:github_import_import_diff_note diff --git a/app/workers/clusters/applications/activate_service_worker.rb b/app/workers/clusters/applications/activate_service_worker.rb new file mode 100644 index 00000000000..4f285d55162 --- /dev/null +++ b/app/workers/clusters/applications/activate_service_worker.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class ActivateServiceWorker + include ApplicationWorker + include ClusterQueue + + def perform(cluster_id, service_name) + cluster = Clusters::Cluster.find_by_id(cluster_id) + return unless cluster + + cluster.all_projects.find_each do |project| + project.find_or_initialize_service(service_name).update!(active: true) + end + end + end + end +end diff --git a/app/workers/clusters/applications/deactivate_service_worker.rb b/app/workers/clusters/applications/deactivate_service_worker.rb new file mode 100644 index 00000000000..2c560cc998c --- /dev/null +++ b/app/workers/clusters/applications/deactivate_service_worker.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class DeactivateServiceWorker + include ApplicationWorker + include ClusterQueue + + def perform(cluster_id, service_name) + cluster = Clusters::Cluster.find_by_id(cluster_id) + raise cluster_missing_error(service_name) unless cluster + + service = "#{service_name}_service".to_sym + cluster.all_projects.with_service(service).find_each do |project| + project.public_send(service).update!(active: false) # rubocop:disable GitlabSecurity/PublicSend + end + end + + def cluster_missing_error(service) + ActiveRecord::RecordNotFound.new("Can't deactivate #{service} services, host cluster not found! Some inconsistent records may be left in database.") + end + end + end +end diff --git a/app/workers/clusters/cleanup/app_worker.rb b/app/workers/clusters/cleanup/app_worker.rb index 1eedf510ba1..8b2fddd3164 100644 --- a/app/workers/clusters/cleanup/app_worker.rb +++ b/app/workers/clusters/cleanup/app_worker.rb @@ -3,13 +3,16 @@ module Clusters module Cleanup class AppWorker - include ApplicationWorker - include ClusterQueue - include ClusterApplications + include ClusterCleanupMethods - # TODO: Merge with https://gitlab.com/gitlab-org/gitlab/merge_requests/16954 - # We're splitting the above MR in smaller chunks to facilitate reviews - def perform + def perform(cluster_id, execution_count = 0) + Clusters::Cluster.with_persisted_applications.find_by_id(cluster_id).try do |cluster| + break unless cluster.cleanup_uninstalling_applications? + + break exceeded_execution_limit(cluster) if exceeded_execution_limit?(execution_count) + + ::Clusters::Cleanup::AppService.new(cluster, execution_count).execute + end end end end diff --git a/app/workers/clusters/cleanup/project_namespace_worker.rb b/app/workers/clusters/cleanup/project_namespace_worker.rb index 09f2abf5d8a..8a7fbf0fde7 100644 --- a/app/workers/clusters/cleanup/project_namespace_worker.rb +++ b/app/workers/clusters/cleanup/project_namespace_worker.rb @@ -3,13 +3,16 @@ module Clusters module Cleanup class ProjectNamespaceWorker - include ApplicationWorker - include ClusterQueue - include ClusterApplications + include ClusterCleanupMethods - # TODO: Merge with https://gitlab.com/gitlab-org/gitlab/merge_requests/16954 - # We're splitting the above MR in smaller chunks to facilitate reviews - def perform + def perform(cluster_id, execution_count = 0) + Clusters::Cluster.find_by_id(cluster_id).try do |cluster| + break unless cluster.cleanup_removing_project_namespaces? + + break exceeded_execution_limit(cluster) if exceeded_execution_limit?(execution_count) + + Clusters::Cleanup::ProjectNamespaceService.new(cluster, execution_count).execute + end end end end diff --git a/app/workers/clusters/cleanup/service_account_worker.rb b/app/workers/clusters/cleanup/service_account_worker.rb index fab6318a807..95de56d8ebe 100644 --- a/app/workers/clusters/cleanup/service_account_worker.rb +++ b/app/workers/clusters/cleanup/service_account_worker.rb @@ -3,13 +3,14 @@ module Clusters module Cleanup class ServiceAccountWorker - include ApplicationWorker - include ClusterQueue - include ClusterApplications + include ClusterCleanupMethods - # TODO: Merge with https://gitlab.com/gitlab-org/gitlab/merge_requests/16954 - # We're splitting the above MR in smaller chunks to facilitate reviews - def perform + def perform(cluster_id) + Clusters::Cluster.find_by_id(cluster_id).try do |cluster| + break unless cluster.cleanup_removing_service_account? + + Clusters::Cleanup::ServiceAccountService.new(cluster).execute + end end end end diff --git a/app/workers/concerns/cluster_cleanup_methods.rb b/app/workers/concerns/cluster_cleanup_methods.rb new file mode 100644 index 00000000000..04fa4d69666 --- /dev/null +++ b/app/workers/concerns/cluster_cleanup_methods.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +# Concern for setting Sidekiq settings for the various GitLab ObjectStorage workers. +module ClusterCleanupMethods + extend ActiveSupport::Concern + + include ApplicationWorker + include ClusterQueue + + DEFAULT_EXECUTION_LIMIT = 10 + ExceededExecutionLimitError = Class.new(StandardError) + + included do + worker_has_external_dependencies! + + sidekiq_options retry: 3 + + sidekiq_retries_exhausted do |msg, error| + cluster_id = msg['args'][0] + + cluster = Clusters::Cluster.find_by_id(cluster_id) + + cluster.make_cleanup_errored!("#{self.class.name} retried too many times") if cluster + + logger = Gitlab::Kubernetes::Logger.build + + logger.error({ + exception: error, + cluster_id: cluster_id, + class_name: msg['class'], + event: :sidekiq_retries_exhausted, + message: msg['error_message'] + }) + end + end + + private + + # Override this method to customize the execution_limit + def execution_limit + DEFAULT_EXECUTION_LIMIT + end + + def exceeded_execution_limit?(execution_count) + execution_count >= execution_limit + end + + def logger + @logger ||= Gitlab::Kubernetes::Logger.build + end + + def exceeded_execution_limit(cluster) + log_exceeded_execution_limit_error(cluster) + + cluster.make_cleanup_errored!("#{self.class.name} exceeded the execution limit") + end + + def cluster_applications_and_status(cluster) + cluster.persisted_applications + .map { |application| "#{application.name}:#{application.status_name}" } + .join(",") + end + + def log_exceeded_execution_limit_error(cluster) + logger.error({ + exception: ExceededExecutionLimitError.name, + cluster_id: cluster.id, + class_name: self.class.name, + cleanup_status: cluster.cleanup_status_name, + applications: cluster_applications_and_status(cluster), + event: :failed_to_remove_cluster_and_resources, + message: "exceeded execution limit of #{execution_limit} tries" + }) + end +end diff --git a/app/workers/delete_merged_branches_worker.rb b/app/workers/delete_merged_branches_worker.rb index 44b3db30d0d..f3d86233c1b 100644 --- a/app/workers/delete_merged_branches_worker.rb +++ b/app/workers/delete_merged_branches_worker.rb @@ -15,7 +15,7 @@ class DeleteMergedBranchesWorker user = User.find(user_id) begin - DeleteMergedBranchesService.new(project, user).execute + ::Branches::DeleteMergedService.new(project, user).execute rescue Gitlab::Access::AccessDeniedError return end diff --git a/app/workers/delete_stored_files_worker.rb b/app/workers/delete_stored_files_worker.rb index 8a693a64055..e1e2f66f573 100644 --- a/app/workers/delete_stored_files_worker.rb +++ b/app/workers/delete_stored_files_worker.rb @@ -15,7 +15,7 @@ class DeleteStoredFilesWorker unless klass message = "Unknown class '#{class_name}'" logger.error(message) - Gitlab::Sentry.track_exception(RuntimeError.new(message)) + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(RuntimeError.new(message)) return end diff --git a/app/workers/hashed_storage/project_migrate_worker.rb b/app/workers/hashed_storage/project_migrate_worker.rb index 8c0ec97638f..0174467923d 100644 --- a/app/workers/hashed_storage/project_migrate_worker.rb +++ b/app/workers/hashed_storage/project_migrate_worker.rb @@ -14,7 +14,7 @@ module HashedStorage try_obtain_lease do project = Project.without_deleted.find_by(id: project_id) - break unless project + break unless project && project.storage_upgradable? old_disk_path ||= Storage::LegacyProject.new(project).disk_path diff --git a/app/workers/pages_domain_removal_cron_worker.rb b/app/workers/pages_domain_removal_cron_worker.rb index b1506831056..07ecde55922 100644 --- a/app/workers/pages_domain_removal_cron_worker.rb +++ b/app/workers/pages_domain_removal_cron_worker.rb @@ -11,7 +11,7 @@ class PagesDomainRemovalCronWorker PagesDomain.for_removal.find_each do |domain| domain.destroy! rescue => e - Raven.capture_exception(e) + Gitlab::ErrorTracking.track_exception(e) end end end diff --git a/app/workers/personal_access_tokens/expiring_worker.rb b/app/workers/personal_access_tokens/expiring_worker.rb new file mode 100644 index 00000000000..f28109c4583 --- /dev/null +++ b/app/workers/personal_access_tokens/expiring_worker.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module PersonalAccessTokens + class ExpiringWorker + include ApplicationWorker + include CronjobQueue + + feature_category :authentication_and_authorization + + def perform(*args) + notification_service = NotificationService.new + limit_date = PersonalAccessToken::DAYS_TO_EXPIRE.days.from_now.to_date + + User.with_expiring_and_not_notified_personal_access_tokens(limit_date).find_each do |user| + notification_service.access_token_about_to_expire(user) + + Rails.logger.info "#{self.class}: Notifying User #{user.id} about expiring tokens" # rubocop:disable Gitlab/RailsLogger + + user.personal_access_tokens.expiring_and_not_notified(limit_date).update_all(expire_notification_delivered: true) + end + end + end +end diff --git a/app/workers/pipeline_process_worker.rb b/app/workers/pipeline_process_worker.rb index 2a36ab992e9..200f3619332 100644 --- a/app/workers/pipeline_process_worker.rb +++ b/app/workers/pipeline_process_worker.rb @@ -11,7 +11,9 @@ class PipelineProcessWorker # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id, build_ids = nil) Ci::Pipeline.find_by(id: pipeline_id).try do |pipeline| - pipeline.process!(build_ids) + Ci::ProcessPipelineService + .new(pipeline) + .execute(build_ids) end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb index 8b4d66ae493..36af51d859e 100644 --- a/app/workers/process_commit_worker.rb +++ b/app/workers/process_commit_worker.rb @@ -55,16 +55,15 @@ class ProcessCommitWorker end end - # rubocop: disable CodeReuse/ActiveRecord def update_issue_metrics(commit, author) mentioned_issues = commit.all_references(author).issues return if mentioned_issues.empty? - Issue::Metrics.where(issue_id: mentioned_issues.map(&:id), first_mentioned_in_commit_at: nil) + Issue::Metrics.for_issues(mentioned_issues) + .with_first_mention_not_earlier_than(commit.committed_date) .update_all(first_mentioned_in_commit_at: commit.committed_date) end - # rubocop: enable CodeReuse/ActiveRecord def build_commit(project, hash) date_suffix = '_date' diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb index 853f774875a..f8f8a2fe7ae 100644 --- a/app/workers/run_pipeline_schedule_worker.rb +++ b/app/workers/run_pipeline_schedule_worker.rb @@ -38,10 +38,10 @@ class RunPipelineScheduleWorker Rails.logger.error "Failed to create a scheduled pipeline. " \ "schedule_id: #{schedule.id} message: #{error.message}" - Gitlab::Sentry - .track_exception(error, + Gitlab::ErrorTracking + .track_and_raise_for_dev_exception(error, issue_url: 'https://gitlab.com/gitlab-org/gitlab-foss/issues/41231', - extra: { schedule_id: schedule.id }) + schedule_id: schedule.id) end # rubocop:enable Gitlab/RailsLogger diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb index b116965d105..d08cea9e494 100644 --- a/app/workers/stuck_ci_jobs_worker.rb +++ b/app/workers/stuck_ci_jobs_worker.rb @@ -80,12 +80,12 @@ class StuckCiJobsWorker end def track_exception_for_build(ex, build) - Gitlab::Sentry.track_acceptable_exception(ex, extra: { + Gitlab::ErrorTracking.track_exception(ex, build_id: build.id, build_name: build.name, build_stage: build.stage, pipeline_id: build.pipeline_id, project_id: build.project_id - }) + ) end end |