diff options
Diffstat (limited to 'app')
410 files changed, 3658 insertions, 1859 deletions
diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue index 155c348286c..b08dc454d12 100644 --- a/app/assets/javascripts/badges/components/badge.vue +++ b/app/assets/javascripts/badges/components/badge.vue @@ -1,13 +1,11 @@ <script> import Icon from '~/vue_shared/components/icon.vue'; -import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; import Tooltip from '~/vue_shared/directives/tooltip'; export default { name: 'Badge', components: { Icon, - LoadingIcon, Tooltip, }, directives: { @@ -80,7 +78,7 @@ export default { /> </a> - <loading-icon + <gl-loading-icon v-show="isLoading" :inline="true" /> diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue index b3f25da87ce..aff7c4180e3 100644 --- a/app/assets/javascripts/badges/components/badge_form.vue +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -4,7 +4,6 @@ import { mapActions, mapState } from 'vuex'; import createFlash from '~/flash'; import { s__, sprintf } from '~/locale'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; -import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; import createEmptyBadge from '../empty_badge'; import Badge from './badge.vue'; @@ -15,7 +14,6 @@ export default { components: { Badge, LoadingButton, - LoadingIcon, }, props: { isEditing: { @@ -207,7 +205,7 @@ export default { :link-url="renderedLinkUrl" /> <p v-show="isRendering"> - <loading-icon + <gl-loading-icon :inline="true" /> </p> diff --git a/app/assets/javascripts/badges/components/badge_list.vue b/app/assets/javascripts/badges/components/badge_list.vue index d2ec0fbb2c0..359d3e10380 100644 --- a/app/assets/javascripts/badges/components/badge_list.vue +++ b/app/assets/javascripts/badges/components/badge_list.vue @@ -1,6 +1,5 @@ <script> import { mapState } from 'vuex'; -import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; import BadgeListRow from './badge_list_row.vue'; import { GROUP_BADGE } from '../constants'; @@ -8,7 +7,6 @@ export default { name: 'BadgeList', components: { BadgeListRow, - LoadingIcon, }, computed: { ...mapState(['badges', 'isLoading', 'kind']), @@ -31,10 +29,10 @@ export default { class="badge badge-pill" >{{ badges.length }}</span> </div> - <loading-icon + <gl-loading-icon v-show="isLoading" + :size="2" class="card-body" - size="2" /> <div v-if="hasNoBadges" diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue index 712d81d0430..5d16ba3ce6d 100644 --- a/app/assets/javascripts/badges/components/badge_list_row.vue +++ b/app/assets/javascripts/badges/components/badge_list_row.vue @@ -2,7 +2,6 @@ import { mapActions, mapState } from 'vuex'; import { s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; -import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; import { PROJECT_BADGE } from '../constants'; import Badge from './badge.vue'; @@ -11,7 +10,6 @@ export default { components: { Badge, Icon, - LoadingIcon, }, props: { badge: { @@ -79,7 +77,7 @@ export default { name="remove" /> </button> - <loading-icon + <gl-loading-icon v-show="badge.isDeleting" :inline="true" /> diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index bfc8d9b03ad..606c9e81db4 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -3,7 +3,6 @@ import Sortable from 'sortablejs'; import boardNewIssue from './board_new_issue.vue'; import boardCard from './board_card.vue'; import eventHub from '../eventhub'; -import loadingIcon from '../../vue_shared/components/loading_icon.vue'; const Store = gl.issueBoards.BoardsStore; @@ -12,7 +11,6 @@ export default { components: { boardCard, boardNewIssue, - loadingIcon, }, props: { groupId: { @@ -217,7 +215,7 @@ export default { v-if="loading" class="board-list-loading text-center" aria-label="Loading issues"> - <loading-icon /> + <gl-loading-icon /> </div> <board-new-issue v-if="list.type !== 'closed' && showIssueForm" @@ -245,7 +243,7 @@ export default { v-if="showCount" class="board-list-count text-center" data-issue-id="-1"> - <loading-icon + <gl-loading-icon v-show="list.loadingMore" label="Loading more issues" /> diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue index 33e72a6782e..0c4c709324d 100644 --- a/app/assets/javascripts/boards/components/modal/index.vue +++ b/app/assets/javascripts/boards/components/modal/index.vue @@ -1,7 +1,6 @@ <script> /* global ListIssue */ - import queryData from '~/boards/utils/query_data'; - import loadingIcon from '~/vue_shared/components/loading_icon.vue'; + import { urlParamsToObject } from '~/lib/utils/common_utils'; import ModalHeader from './header.vue'; import ModalList from './list.vue'; import ModalFooter from './footer.vue'; @@ -14,7 +13,6 @@ ModalHeader, ModalList, ModalFooter, - loadingIcon, }, props: { newIssuePath: { @@ -109,13 +107,11 @@ loadIssues(clearIssues = false) { if (!this.showAddIssuesModal) return false; - return gl.boardService - .getBacklog( - queryData(this.filter.path, { - page: this.page, - per: this.perPage, - }), - ) + return gl.boardService.getBacklog({ + ...urlParamsToObject(this.filter.path), + page: this.page, + per: this.perPage, + }) .then(res => res.data) .then(data => { if (clearIssues) { @@ -169,7 +165,7 @@ class="add-issues-list text-center" > <div class="add-issues-list-loading"> - <loading-icon /> + <gl-loading-icon /> </div> </section> <modal-footer/> diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index ef9844d5562..d4676914e02 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -2,14 +2,10 @@ import $ from 'jquery'; import _ from 'underscore'; import eventHub from '../eventhub'; -import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import Api from '../../api'; export default { name: 'BoardProjectSelect', - components: { - loadingIcon, - }, props: { groupId: { type: Number, @@ -119,7 +115,7 @@ export default { </div> <div class="dropdown-content"></div> <div class="dropdown-loading"> - <loading-icon /> + <gl-loading-icon /> </div> </div> </div> diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index ad473404c29..d416b76f0f4 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -4,7 +4,7 @@ import { __ } from '~/locale'; import ListLabel from '~/vue_shared/models/label'; import ListAssignee from '~/vue_shared/models/assignee'; -import queryData from '../utils/query_data'; +import { urlParamsToObject } from '~/lib/utils/common_utils'; const PER_PAGE = 20; @@ -115,7 +115,10 @@ class List { } getIssues(emptyIssues = true) { - const data = queryData(gl.issueBoards.BoardsStore.filter.path, { page: this.page }); + const data = { + ...urlParamsToObject(gl.issueBoards.BoardsStore.filter.path), + page: this.page, + }; if (this.label && data.label_name) { data.label_name = data.label_name.filter(label => label !== this.label.title); diff --git a/app/assets/javascripts/boards/utils/query_data.js b/app/assets/javascripts/boards/utils/query_data.js deleted file mode 100644 index 65315979df7..00000000000 --- a/app/assets/javascripts/boards/utils/query_data.js +++ /dev/null @@ -1,21 +0,0 @@ -export default (path, extraData) => path.split('&').reduce((dataParam, filterParam) => { - if (filterParam === '') return dataParam; - - const data = dataParam; - const paramSplit = filterParam.split('='); - const paramKeyNormalized = paramSplit[0].replace('[]', ''); - const isArray = paramSplit[0].indexOf('[]'); - const value = decodeURIComponent(paramSplit[1].replace(/\+/g, ' ')); - - if (isArray !== -1) { - if (!data[paramKeyNormalized]) { - data[paramKeyNormalized] = []; - } - - data[paramKeyNormalized].push(value); - } else { - data[paramKeyNormalized] = value; - } - - return data; -}, extraData); diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 0fdf0c7a389..ebf76af5966 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -1,16 +1,12 @@ import Visibility from 'visibilityjs'; import Vue from 'vue'; +import initDismissableCallout from '~/dismissable_callout'; import { s__, sprintf } from '../locale'; import Flash from '../flash'; import Poll from '../lib/utils/poll'; import initSettingsPanels from '../settings_panels'; import eventHub from './event_hub'; -import { - APPLICATION_STATUS, - REQUEST_LOADING, - REQUEST_SUCCESS, - REQUEST_FAILURE, -} from './constants'; +import { APPLICATION_STATUS, REQUEST_LOADING, REQUEST_SUCCESS, REQUEST_FAILURE } from './constants'; import ClustersService from './services/clusters_service'; import ClustersStore from './stores/clusters_store'; import applications from './components/applications.vue'; @@ -66,6 +62,7 @@ export default class Clusters { this.showTokenButton = document.querySelector('.js-show-cluster-token'); this.tokenField = document.querySelector('.js-cluster-token'); + initDismissableCallout('.js-cluster-security-warning'); initSettingsPanels(); setupToggleButtons(document.querySelector('.js-cluster-enable-toggle-area')); this.initApplications(); @@ -129,7 +126,8 @@ export default class Clusters { if (!Visibility.hidden()) { this.poll.makeRequest(); } else { - this.service.fetchData() + this.service + .fetchData() .then(data => this.handleSuccess(data)) .catch(() => Clusters.handleError()); } @@ -177,15 +175,21 @@ export default class Clusters { checkForNewInstalls(prevApplicationMap, newApplicationMap) { const appTitles = Object.keys(newApplicationMap) - .filter(appId => newApplicationMap[appId].status === APPLICATION_STATUS.INSTALLED && - prevApplicationMap[appId].status !== APPLICATION_STATUS.INSTALLED && - prevApplicationMap[appId].status !== null) + .filter( + appId => + newApplicationMap[appId].status === APPLICATION_STATUS.INSTALLED && + prevApplicationMap[appId].status !== APPLICATION_STATUS.INSTALLED && + prevApplicationMap[appId].status !== null, + ) .map(appId => newApplicationMap[appId].title); if (appTitles.length > 0) { - const text = sprintf(s__('ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster'), { - appList: appTitles.join(', '), - }); + const text = sprintf( + s__('ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster'), + { + appList: appTitles.join(', '), + }, + ); Flash(text, 'notice', this.successApplicationContainer); } } @@ -218,13 +222,18 @@ export default class Clusters { this.store.updateAppProperty(appId, 'requestStatus', REQUEST_LOADING); this.store.updateAppProperty(appId, 'requestReason', null); - this.service.installApplication(appId, data.params) + this.service + .installApplication(appId, data.params) .then(() => { this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUCCESS); }) .catch(() => { this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE); - this.store.updateAppProperty(appId, 'requestReason', s__('ClusterIntegration|Request to begin installing failed')); + this.store.updateAppProperty( + appId, + 'requestReason', + s__('ClusterIntegration|Request to begin installing failed'), + ); }); } diff --git a/app/assets/javascripts/clusters/clusters_index.js b/app/assets/javascripts/clusters/clusters_index.js index 1e5c733d151..789c8360124 100644 --- a/app/assets/javascripts/clusters/clusters_index.js +++ b/app/assets/javascripts/clusters/clusters_index.js @@ -1,14 +1,14 @@ import createFlash from '~/flash'; import { __ } from '~/locale'; import setupToggleButtons from '~/toggle_buttons'; -import gcpSignupOffer from '~/clusters/components/gcp_signup_offer'; +import initDismissableCallout from '~/dismissable_callout'; import ClustersService from './services/clusters_service'; export default () => { const clusterList = document.querySelector('.js-clusters-list'); - gcpSignupOffer(); + initDismissableCallout('.gcp-signup-offer'); // The empty state won't have a clusterList if (clusterList) { diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue index 95c4be64d35..4849b0fa3db 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue @@ -76,10 +76,10 @@ <template> <div class="content-list pipelines"> - <loading-icon + <gl-loading-icon v-if="isLoading" :label="s__('Pipelines|Loading Pipelines')" - size="3" + :size="3" class="prepend-top-20" /> diff --git a/app/assets/javascripts/commons/gitlab_ui.js b/app/assets/javascripts/commons/gitlab_ui.js index 923c036f5a4..aed26adfa5c 100644 --- a/app/assets/javascripts/commons/gitlab_ui.js +++ b/app/assets/javascripts/commons/gitlab_ui.js @@ -1,4 +1,16 @@ import Vue from 'vue'; -import progressBar from '@gitlab-org/gitlab-ui/dist/base/progress_bar'; +import Pagination from '@gitlab-org/gitlab-ui/dist/components/base/pagination'; +import progressBar from '@gitlab-org/gitlab-ui/dist/components/base/progress_bar'; +import modal from '@gitlab-org/gitlab-ui/dist/components/base/modal'; +import loadingIcon from '@gitlab-org/gitlab-ui/dist/components/base/loading_icon'; +import dModal from '@gitlab-org/gitlab-ui/dist/directives/modal'; +import dTooltip from '@gitlab-org/gitlab-ui/dist/directives/tooltip'; + +Vue.component('gl-pagination', Pagination); Vue.component('gl-progress-bar', progressBar); +Vue.component('gl-ui-modal', modal); +Vue.component('gl-loading-icon', loadingIcon); + +Vue.directive('gl-modal', dModal); +Vue.directive('gl-tooltip', dTooltip); diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue index 7399fc97d45..10548da8ec5 100644 --- a/app/assets/javascripts/deploy_keys/components/action_btn.vue +++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue @@ -1,11 +1,7 @@ <script> -import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import eventHub from '../eventhub'; export default { - components: { - loadingIcon, - }, props: { deployKey: { type: Object, @@ -45,7 +41,7 @@ export default { class="btn" @click="doAction"> <slot></slot> - <loading-icon + <gl-loading-icon v-if="isLoading" :inline="true" /> diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index d91e4809126..aa52f120fe7 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -1,7 +1,6 @@ <script> import { s__ } from '~/locale'; import Flash from '~/flash'; -import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; import eventHub from '../eventhub'; import DeployKeysService from '../service'; @@ -11,7 +10,6 @@ import KeysPanel from './keys_panel.vue'; export default { components: { KeysPanel, - LoadingIcon, NavigationTabs, }, props: { @@ -114,10 +112,10 @@ export default { <template> <div class="append-bottom-default deploy-keys"> - <loading-icon + <gl-loading-icon v-if="isLoading && !hasKeys" :label="s__('DeployKeys|Loading deploy keys')" - size="2" + :size="2" /> <template v-else-if="hasKeys"> <div class="top-area scrolling-tabs-container inner-page-scroll-tabs"> diff --git a/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js b/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js index 5ed13488788..6fcad187b35 100644 --- a/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js +++ b/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js @@ -1,4 +1,4 @@ -/* eslint-disable object-shorthand, func-names, comma-dangle, no-else-return, quotes */ +/* eslint-disable object-shorthand, func-names, no-else-return */ /* global CommentsStore */ /* global ResolveService */ @@ -25,44 +25,44 @@ const ResolveDiscussionBtn = Vue.extend({ }; }, computed: { - showButton: function () { + showButton: function() { if (this.discussion) { return this.discussion.isResolvable(); } else { return false; } }, - isDiscussionResolved: function () { + isDiscussionResolved: function() { if (this.discussion) { return this.discussion.isResolved(); } else { return false; } }, - buttonText: function () { + buttonText: function() { if (this.isDiscussionResolved) { - return "Unresolve discussion"; + return 'Unresolve discussion'; } else { - return "Resolve discussion"; + return 'Resolve discussion'; } }, - loading: function () { + loading: function() { if (this.discussion) { return this.discussion.loading; } else { return false; } - } + }, }, - created: function () { + created: function() { CommentsStore.createDiscussion(this.discussionId, this.canResolve); this.discussion = CommentsStore.state[this.discussionId]; }, methods: { - resolve: function () { + resolve: function() { ResolveService.toggleResolveForDiscussion(this.mergeRequestId, this.discussionId); - } + }, }, }); diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js index 0b3568e432d..e69eaad4423 100644 --- a/app/assets/javascripts/diff_notes/services/resolve.js +++ b/app/assets/javascripts/diff_notes/services/resolve.js @@ -8,9 +8,7 @@ window.gl = window.gl || {}; class ResolveServiceClass { constructor(root) { - this.noteResource = Vue.resource( - `${root}/notes{/noteId}/resolve?html=true`, - ); + this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve?html=true`); this.discussionResource = Vue.resource( `${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve?html=true`, ); @@ -51,10 +49,7 @@ class ResolveServiceClass { discussion.updateHeadline(data); }) .catch( - () => - new Flash( - 'An error occurred when trying to resolve a discussion. Please try again.', - ), + () => new Flash('An error occurred when trying to resolve a discussion. Please try again.'), ); } diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index b5b05df4d34..bfb992340bc 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -4,7 +4,6 @@ import Icon from '~/vue_shared/components/icon.vue'; import { __ } from '~/locale'; import createFlash from '~/flash'; import eventHub from '../../notes/event_hub'; -import LoadingIcon from '../../vue_shared/components/loading_icon.vue'; import CompareVersions from './compare_versions.vue'; import ChangedFiles from './changed_files.vue'; import DiffFile from './diff_file.vue'; @@ -15,7 +14,6 @@ export default { name: 'DiffsApp', components: { Icon, - LoadingIcon, CompareVersions, ChangedFiles, DiffFile, @@ -59,7 +57,7 @@ export default { emailPatchPath: state => state.diffs.emailPatchPath, }), ...mapGetters('diffs', ['isParallelView']), - ...mapGetters(['isNotesFetched']), + ...mapGetters(['isNotesFetched', 'discussionsStructuredByLineCode']), targetBranch() { return { branchName: this.targetBranchName, @@ -112,13 +110,26 @@ export default { }, created() { this.adjustView(); + eventHub.$once('fetchedNotesData', this.setDiscussions); }, methods: { - ...mapActions('diffs', ['setBaseConfig', 'fetchDiffFiles', 'startRenderDiffsQueue']), + ...mapActions('diffs', [ + 'setBaseConfig', + 'fetchDiffFiles', + 'startRenderDiffsQueue', + 'assignDiscussionsToDiff', + ]), + fetchData() { this.fetchDiffFiles() .then(() => { - requestIdleCallback(this.startRenderDiffsQueue, { timeout: 1000 }); + requestIdleCallback( + () => { + this.setDiscussions(); + this.startRenderDiffsQueue(); + }, + { timeout: 1000 }, + ); }) .catch(() => { createFlash(__('Something went wrong on our end. Please try again!')); @@ -128,6 +139,16 @@ export default { eventHub.$emit('fetchNotesData'); } }, + setDiscussions() { + if (this.isNotesFetched) { + requestIdleCallback( + () => { + this.assignDiscussionsToDiff(this.discussionsStructuredByLineCode); + }, + { timeout: 1000 }, + ); + } + }, adjustView() { if (this.shouldShow && this.isParallelView) { window.mrTabs.expandViewContainer(); @@ -145,7 +166,7 @@ export default { v-if="isLoading" class="loading" > - <loading-icon /> + <gl-loading-icon /> </div> <div v-else diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue index e64d5511d78..cddbe554fbd 100644 --- a/app/assets/javascripts/diffs/components/diff_discussions.vue +++ b/app/assets/javascripts/diffs/components/diff_discussions.vue @@ -1,4 +1,5 @@ <script> +import { mapActions } from 'vuex'; import noteableDiscussion from '../../notes/components/noteable_discussion.vue'; export default { @@ -11,6 +12,14 @@ export default { required: true, }, }, + methods: { + ...mapActions('diffs', ['removeDiscussionsFromDiff']), + deleteNoteHandler(discussion) { + if (discussion.notes.length <= 1) { + this.removeDiscussionsFromDiff(discussion); + } + }, + }, }; </script> @@ -31,6 +40,7 @@ export default { :render-diff-file="false" :always-expanded="true" :discussions-by-diff-order="true" + @noteDeleted="deleteNoteHandler" /> </ul> </div> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 59e9ba08b8b..940e06a75cc 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -1,9 +1,8 @@ <script> -import { mapActions } from 'vuex'; +import { mapActions, mapGetters } from 'vuex'; import _ from 'underscore'; import { __, sprintf } from '~/locale'; import createFlash from '~/flash'; -import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; import DiffFileHeader from './diff_file_header.vue'; import DiffContent from './diff_content.vue'; @@ -11,7 +10,6 @@ export default { components: { DiffFileHeader, DiffContent, - LoadingIcon, }, props: { file: { @@ -30,6 +28,7 @@ export default { }; }, computed: { + ...mapGetters(['isNotesFetched', 'discussionsStructuredByLineCode']), isCollapsed() { return this.file.collapsed || false; }, @@ -44,23 +43,23 @@ export default { ); }, showExpandMessage() { - return this.isCollapsed && !this.isLoadingCollapsedDiff && !this.file.tooLarge; + return ( + !this.isCollapsed && + !this.file.highlightedDiffLines && + !this.isLoadingCollapsedDiff && + !this.file.tooLarge && + this.file.text + ); }, showLoadingIcon() { return this.isLoadingCollapsedDiff || (!this.file.renderIt && !this.isCollapsed); }, }, methods: { - ...mapActions('diffs', ['loadCollapsedDiff']), + ...mapActions('diffs', ['loadCollapsedDiff', 'assignDiscussionsToDiff']), handleToggle() { - const { collapsed, highlightedDiffLines, parallelDiffLines } = this.file; - - if ( - collapsed && - !highlightedDiffLines && - parallelDiffLines !== undefined && - !parallelDiffLines.length - ) { + const { highlightedDiffLines, parallelDiffLines } = this.file; + if (!highlightedDiffLines && parallelDiffLines !== undefined && !parallelDiffLines.length) { this.handleLoadCollapsedDiff(); } else { this.file.collapsed = !this.file.collapsed; @@ -76,6 +75,14 @@ export default { this.file.collapsed = false; this.file.renderIt = true; }) + .then(() => { + requestIdleCallback( + () => { + this.assignDiscussionsToDiff(this.discussionsStructuredByLineCode); + }, + { timeout: 1000 }, + ); + }) .catch(() => { this.isLoadingCollapsedDiff = false; createFlash(__('Something went wrong on our end. Please try again!')); @@ -135,12 +142,12 @@ export default { :class="{ hidden: isCollapsed || file.tooLarge }" :diff-file="file" /> - <loading-icon - v-else-if="showLoadingIcon" + <gl-loading-icon + v-if="showLoadingIcon" class="diff-content loading" /> <div - v-if="showExpandMessage" + v-else-if="showExpandMessage" class="nothing-here-block diff-collapsed" > {{ __('This diff is collapsed.') }} diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue index 7e50a0aed84..a02c41f39ab 100644 --- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue +++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue @@ -1,15 +1,11 @@ <script> import { mapActions } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; -import tooltip from '~/vue_shared/directives/tooltip'; import { pluralize, truncate } from '~/lib/utils/text_utility'; import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; import { COUNT_OF_AVATARS_IN_GUTTER, LENGTH_OF_AVATAR_TOOLTIP } from '../constants'; export default { - directives: { - tooltip, - }, components: { Icon, UserAvatarImage, @@ -91,10 +87,10 @@ export default { @click.native="toggleDiscussions" /> <span - v-tooltip + v-gl-tooltip v-if="moreText" :title="moreText" - class="diff-comments-more-count has-tooltip js-diff-comment-avatar js-diff-comment-plus" + class="diff-comments-more-count js-diff-comment-avatar js-diff-comment-plus" data-container="body" data-placement="top" role="button" 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 8ad1ea34245..6eff3013dcd 100644 --- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue +++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue @@ -13,6 +13,10 @@ export default { Icon, }, props: { + line: { + type: Object, + required: true, + }, fileHash: { type: String, required: true, @@ -21,31 +25,16 @@ export default { type: String, required: true, }, - lineType: { - type: String, - required: false, - default: '', - }, lineNumber: { type: Number, required: false, default: 0, }, - lineCode: { - type: String, - required: false, - default: '', - }, linePosition: { type: String, required: false, default: '', }, - metaData: { - type: Object, - required: false, - default: () => ({}), - }, showCommentButton: { type: Boolean, required: false, @@ -76,11 +65,6 @@ export default { required: false, default: false, }, - discussions: { - type: Array, - required: false, - default: () => [], - }, }, computed: { ...mapState({ @@ -89,7 +73,7 @@ export default { }), ...mapGetters(['isLoggedIn']), lineHref() { - return this.lineCode ? `#${this.lineCode}` : '#'; + return `#${this.line.lineCode || ''}`; }, shouldShowCommentButton() { return ( @@ -103,20 +87,19 @@ export default { ); }, hasDiscussions() { - return this.discussions.length > 0; + return this.line.discussions && this.line.discussions.length > 0; }, shouldShowAvatarsOnGutter() { - if (!this.lineType && this.linePosition === LINE_POSITION_RIGHT) { + if (!this.line.type && this.linePosition === LINE_POSITION_RIGHT) { return false; } - return this.showCommentButton && this.hasDiscussions; }, }, methods: { ...mapActions('diffs', ['loadMoreLines', 'showCommentForm']), handleCommentButton() { - this.showCommentForm({ lineCode: this.lineCode }); + this.showCommentForm({ lineCode: this.line.lineCode }); }, handleLoadMoreLines() { if (this.isRequesting) { @@ -125,8 +108,8 @@ export default { this.isRequesting = true; const endpoint = this.contextLinesPath; - const oldLineNumber = this.metaData.oldPos || 0; - const newLineNumber = this.metaData.newPos || 0; + const oldLineNumber = this.line.metaData.oldPos || 0; + const newLineNumber = this.line.metaData.newPos || 0; const offset = newLineNumber - oldLineNumber; const bottom = this.isBottom; const { fileHash } = this; @@ -201,7 +184,7 @@ export default { </a> <diff-gutter-avatars v-if="shouldShowAvatarsOnGutter" - :discussions="discussions" + :discussions="line.discussions" /> </template> </div> 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 cbe4551d06b..a0dc381ccc7 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -6,6 +6,7 @@ import noteForm from '../../notes/components/note_form.vue'; import { getNoteFormData } from '../store/utils'; import autosave from '../../notes/mixins/autosave'; import { DIFF_NOTE_TYPE } from '../constants'; +import { reduceDiscussionsToLineCodes } from '../../notes/stores/utils'; export default { components: { @@ -52,7 +53,7 @@ export default { } }, methods: { - ...mapActions('diffs', ['cancelCommentForm']), + ...mapActions('diffs', ['cancelCommentForm', 'assignDiscussionsToDiff']), ...mapActions(['saveNote', 'refetchDiscussionById']), handleCancelCommentForm(shouldConfirm, isDirty) { if (shouldConfirm && isDirty) { @@ -88,7 +89,10 @@ export default { const endpoint = this.getNotesDataByProp('discussionsPath'); this.refetchDiscussionById({ path: endpoint, discussionId: result.discussion_id }) - .then(() => { + .then(selectedDiscussion => { + const lineCodeDiscussions = reduceDiscussionsToLineCodes([selectedDiscussion]); + this.assignDiscussionsToDiff(lineCodeDiscussions); + this.handleCancelCommentForm(); }) .catch(() => { diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue index 33bc8d9971e..5d9a0b123fe 100644 --- a/app/assets/javascripts/diffs/components/diff_table_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue @@ -11,8 +11,6 @@ import { LINE_HOVER_CLASS_NAME, LINE_UNFOLD_CLASS_NAME, INLINE_DIFF_VIEW_TYPE, - LINE_POSITION_LEFT, - LINE_POSITION_RIGHT, } from '../constants'; export default { @@ -67,42 +65,24 @@ export default { required: false, default: false, }, - discussions: { - type: Array, - required: false, - default: () => [], - }, }, computed: { ...mapGetters(['isLoggedIn']), - normalizedLine() { - let normalizedLine; - - if (this.diffViewType === INLINE_DIFF_VIEW_TYPE) { - normalizedLine = this.line; - } else if (this.linePosition === LINE_POSITION_LEFT) { - normalizedLine = this.line.left; - } else if (this.linePosition === LINE_POSITION_RIGHT) { - normalizedLine = this.line.right; - } - - return normalizedLine; - }, isMatchLine() { - return this.normalizedLine.type === MATCH_LINE_TYPE; + return this.line.type === MATCH_LINE_TYPE; }, isContextLine() { - return this.normalizedLine.type === CONTEXT_LINE_TYPE; + return this.line.type === CONTEXT_LINE_TYPE; }, isMetaLine() { - const { type } = this.normalizedLine; + const { type } = this.line; return ( type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE || type === EMPTY_CELL_TYPE ); }, classNameMap() { - const { type } = this.normalizedLine; + const { type } = this.line; return { [type]: type, @@ -116,9 +96,9 @@ export default { }; }, lineNumber() { - const { lineType, normalizedLine } = this; + const { lineType } = this; - return lineType === OLD_LINE_TYPE ? normalizedLine.oldLine : normalizedLine.newLine; + return lineType === OLD_LINE_TYPE ? this.line.oldLine : this.line.newLine; }, }, }; @@ -129,20 +109,17 @@ export default { :class="classNameMap" > <diff-line-gutter-content + :line="line" :file-hash="fileHash" :context-lines-path="contextLinesPath" - :line-type="normalizedLine.type" - :line-code="normalizedLine.lineCode" :line-position="linePosition" :line-number="lineNumber" - :meta-data="normalizedLine.metaData" :show-comment-button="showCommentButton" :is-hover="isHover" :is-bottom="isBottom" :is-match-line="isMatchLine" :is-context-line="isContentLine" :is-meta-line="isMetaLine" - :discussions="discussions" /> </td> </template> diff --git a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue index 6348f32d36d..46a51859da5 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue @@ -21,18 +21,13 @@ export default { type: Number, required: true, }, - discussions: { - type: Array, - required: false, - default: () => [], - }, }, computed: { ...mapState({ diffLineCommentForms: state => state.diffs.diffLineCommentForms, }), className() { - return this.discussions.length ? '' : 'js-temp-notes-holder'; + return this.line.discussions.length ? '' : 'js-temp-notes-holder'; }, }, }; @@ -49,8 +44,8 @@ export default { > <div class="content"> <diff-discussions - v-if="discussions.length" - :discussions="discussions" + v-if="line.discussions.length" + :discussions="line.discussions" /> <diff-line-note-form v-if="diffLineCommentForms[line.lineCode]" diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue index 32d65ff994f..0e306f39a9f 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue @@ -33,11 +33,6 @@ export default { required: false, default: false, }, - discussions: { - type: Array, - required: false, - default: () => [], - }, }, data() { return { @@ -94,7 +89,6 @@ export default { :is-bottom="isBottom" :is-hover="isHover" :show-comment-button="true" - :discussions="discussions" class="diff-line-num old_line" /> <diff-table-cell @@ -104,7 +98,6 @@ export default { :line-type="newLineType" :is-bottom="isBottom" :is-hover="isHover" - :discussions="discussions" class="diff-line-num new_line" /> <td diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue index e7d789734c3..947e7c98fae 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_view.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue @@ -2,7 +2,6 @@ import { mapGetters, mapState } from 'vuex'; import inlineDiffTableRow from './inline_diff_table_row.vue'; import inlineDiffCommentRow from './inline_diff_comment_row.vue'; -import { trimFirstCharOfLineContent } from '../store/utils'; export default { components: { @@ -20,29 +19,17 @@ export default { }, }, computed: { - ...mapGetters('diffs', [ - 'commitId', - 'shouldRenderInlineCommentRow', - 'singleDiscussionByLineCode', - ]), + ...mapGetters('diffs', ['commitId', 'shouldRenderInlineCommentRow']), ...mapState({ diffLineCommentForms: state => state.diffs.diffLineCommentForms, }), - normalizedDiffLines() { - return this.diffLines.map(line => (line.richText ? trimFirstCharOfLineContent(line) : line)); - }, diffLinesLength() { - return this.normalizedDiffLines.length; + return this.diffLines.length; }, userColorScheme() { return window.gon.user_color_scheme; }, }, - methods: { - discussionsList(line) { - return line.lineCode !== undefined ? this.singleDiscussionByLineCode(line.lineCode) : []; - }, - }, }; </script> @@ -53,7 +40,7 @@ export default { class="code diff-wrap-lines js-syntax-highlight text-file js-diff-inline-view"> <tbody> <template - v-for="(line, index) in normalizedDiffLines" + v-for="(line, index) in diffLines" > <inline-diff-table-row :file-hash="diffFile.fileHash" @@ -61,7 +48,6 @@ export default { :line="line" :is-bottom="index + 1 === diffLinesLength" :key="line.lineCode" - :discussions="discussionsList(line)" /> <inline-diff-comment-row v-if="shouldRenderInlineCommentRow(line)" @@ -69,7 +55,6 @@ export default { :line="line" :line-index="index" :key="index" - :discussions="discussionsList(line)" /> </template> </tbody> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue index 48b8feeb0b4..26417c350cb 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue @@ -21,51 +21,49 @@ export default { type: Number, required: true, }, - leftDiscussions: { - type: Array, - required: false, - default: () => [], - }, - rightDiscussions: { - type: Array, - required: false, - default: () => [], - }, }, computed: { ...mapState({ diffLineCommentForms: state => state.diffs.diffLineCommentForms, }), leftLineCode() { - return this.line.left.lineCode; + return this.line.left && this.line.left.lineCode; }, rightLineCode() { - return this.line.right.lineCode; + return this.line.right && this.line.right.lineCode; }, hasExpandedDiscussionOnLeft() { - const discussions = this.leftDiscussions; - - return discussions ? discussions.every(discussion => discussion.expanded) : false; + return this.line.left && this.line.left.discussions + ? this.line.left.discussions.every(discussion => discussion.expanded) + : false; }, hasExpandedDiscussionOnRight() { - const discussions = this.rightDiscussions; - - return discussions ? discussions.every(discussion => discussion.expanded) : false; + return this.line.right && this.line.right.discussions + ? this.line.right.discussions.every(discussion => discussion.expanded) + : false; }, hasAnyExpandedDiscussion() { return this.hasExpandedDiscussionOnLeft || this.hasExpandedDiscussionOnRight; }, shouldRenderDiscussionsOnLeft() { - return this.leftDiscussions && this.hasExpandedDiscussionOnLeft; + return this.line.left && this.line.left.discussions && this.hasExpandedDiscussionOnLeft; }, shouldRenderDiscussionsOnRight() { - return this.rightDiscussions && this.hasExpandedDiscussionOnRight && this.line.right.type; + return ( + this.line.right && + this.line.right.discussions && + this.hasExpandedDiscussionOnRight && + this.line.right.type + ); }, showRightSideCommentForm() { - return this.line.right.type && this.diffLineCommentForms[this.rightLineCode]; + return ( + this.line.right && this.line.right.type && this.diffLineCommentForms[this.rightLineCode] + ); }, className() { - return this.leftDiscussions.length > 0 || this.rightDiscussions.length > 0 + return (this.left && this.line.left.discussions.length > 0) || + (this.right && this.line.right.discussions.length > 0) ? '' : 'js-temp-notes-holder'; }, @@ -85,8 +83,8 @@ export default { class="content" > <diff-discussions - v-if="leftDiscussions.length" - :discussions="leftDiscussions" + v-if="line.left.discussions.length" + :discussions="line.left.discussions" /> </div> <diff-line-note-form @@ -104,8 +102,8 @@ export default { class="content" > <diff-discussions - v-if="rightDiscussions.length" - :discussions="rightDiscussions" + v-if="line.right.discussions.length" + :discussions="line.right.discussions" /> </div> <diff-line-note-form diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue index d4e54c2bd00..fb68d191091 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue @@ -1,6 +1,5 @@ <script> import $ from 'jquery'; -import { mapGetters } from 'vuex'; import DiffTableCell from './diff_table_cell.vue'; import { NEW_LINE_TYPE, @@ -10,8 +9,7 @@ import { OLD_NO_NEW_LINE_TYPE, PARALLEL_DIFF_VIEW_TYPE, NEW_NO_NEW_LINE_TYPE, - LINE_POSITION_LEFT, - LINE_POSITION_RIGHT, + EMPTY_CELL_TYPE, } from '../constants'; export default { @@ -36,16 +34,6 @@ export default { required: false, default: false, }, - leftDiscussions: { - type: Array, - required: false, - default: () => [], - }, - rightDiscussions: { - type: Array, - required: false, - default: () => [], - }, }, data() { return { @@ -54,29 +42,26 @@ export default { }; }, computed: { - ...mapGetters('diffs', ['isParallelView']), isContextLine() { - return this.line.left.type === CONTEXT_LINE_TYPE; + return this.line.left && this.line.left.type === CONTEXT_LINE_TYPE; }, classNameMap() { return { [CONTEXT_LINE_CLASS_NAME]: this.isContextLine, - [PARALLEL_DIFF_VIEW_TYPE]: this.isParallelView, + [PARALLEL_DIFF_VIEW_TYPE]: true, }; }, parallelViewLeftLineType() { - if (this.line.right.type === NEW_NO_NEW_LINE_TYPE) { + if (this.line.right && this.line.right.type === NEW_NO_NEW_LINE_TYPE) { return OLD_NO_NEW_LINE_TYPE; } - return this.line.left.type; + return this.line.left ? this.line.left.type : EMPTY_CELL_TYPE; }, }, created() { this.newLineType = NEW_LINE_TYPE; this.oldLineType = OLD_LINE_TYPE; - this.linePositionLeft = LINE_POSITION_LEFT; - this.linePositionRight = LINE_POSITION_RIGHT; this.parallelDiffViewType = PARALLEL_DIFF_VIEW_TYPE; }, methods: { @@ -116,47 +101,57 @@ export default { @mouseover="handleMouseMove" @mouseout="handleMouseMove" > - <diff-table-cell - :file-hash="fileHash" - :context-lines-path="contextLinesPath" - :line="line" - :line-type="oldLineType" - :line-position="linePositionLeft" - :is-bottom="isBottom" - :is-hover="isLeftHover" - :show-comment-button="true" - :diff-view-type="parallelDiffViewType" - :discussions="leftDiscussions" - class="diff-line-num old_line" - /> - <td - :id="line.left.lineCode" - :class="parallelViewLeftLineType" - class="line_content parallel left-side" - @mousedown.native="handleParallelLineMouseDown" - v-html="line.left.richText" - > - </td> - <diff-table-cell - :file-hash="fileHash" - :context-lines-path="contextLinesPath" - :line="line" - :line-type="newLineType" - :line-position="linePositionRight" - :is-bottom="isBottom" - :is-hover="isRightHover" - :show-comment-button="true" - :diff-view-type="parallelDiffViewType" - :discussions="rightDiscussions" - class="diff-line-num new_line" - /> - <td - :id="line.right.lineCode" - :class="line.right.type" - class="line_content parallel right-side" - @mousedown.native="handleParallelLineMouseDown" - v-html="line.right.richText" - > - </td> + <template v-if="line.left"> + <diff-table-cell + :file-hash="fileHash" + :context-lines-path="contextLinesPath" + :line="line.left" + :line-type="oldLineType" + :is-bottom="isBottom" + :is-hover="isLeftHover" + :show-comment-button="true" + :diff-view-type="parallelDiffViewType" + line-position="left" + class="diff-line-num old_line" + /> + <td + :id="line.left.lineCode" + :class="parallelViewLeftLineType" + class="line_content parallel left-side" + @mousedown.native="handleParallelLineMouseDown" + v-html="line.left.richText" + > + </td> + </template> + <template v-else> + <td class="diff-line-num old_line empty-cell"></td> + <td class="line_content parallel left-side empty-cell"></td> + </template> + <template v-if="line.right"> + <diff-table-cell + :file-hash="fileHash" + :context-lines-path="contextLinesPath" + :line="line.right" + :line-type="newLineType" + :is-bottom="isBottom" + :is-hover="isRightHover" + :show-comment-button="true" + :diff-view-type="parallelDiffViewType" + line-position="right" + class="diff-line-num new_line" + /> + <td + :id="line.right.lineCode" + :class="line.right.type" + class="line_content parallel right-side" + @mousedown.native="handleParallelLineMouseDown" + v-html="line.right.richText" + > + </td> + </template> + <template v-else> + <td class="diff-line-num old_line empty-cell"></td> + <td class="line_content parallel right-side empty-cell"></td> + </template> </tr> </template> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue index 24ceb52a04a..501bd4450d8 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue @@ -2,8 +2,6 @@ import { mapState, mapGetters } from 'vuex'; import parallelDiffTableRow from './parallel_diff_table_row.vue'; import parallelDiffCommentRow from './parallel_diff_comment_row.vue'; -import { EMPTY_CELL_TYPE } from '../constants'; -import { trimFirstCharOfLineContent } from '../store/utils'; export default { components: { @@ -21,46 +19,17 @@ export default { }, }, computed: { - ...mapGetters('diffs', [ - 'commitId', - 'singleDiscussionByLineCode', - 'shouldRenderParallelCommentRow', - ]), + ...mapGetters('diffs', ['commitId', 'shouldRenderParallelCommentRow']), ...mapState({ diffLineCommentForms: state => state.diffs.diffLineCommentForms, }), - parallelDiffLines() { - return this.diffLines.map(line => { - const parallelLine = Object.assign({}, line); - - if (line.left) { - parallelLine.left = trimFirstCharOfLineContent(line.left); - } else { - parallelLine.left = { type: EMPTY_CELL_TYPE }; - } - - if (line.right) { - parallelLine.right = trimFirstCharOfLineContent(line.right); - } else { - parallelLine.right = { type: EMPTY_CELL_TYPE }; - } - - return parallelLine; - }); - }, diffLinesLength() { - return this.parallelDiffLines.length; + return this.diffLines.length; }, userColorScheme() { return window.gon.user_color_scheme; }, }, - methods: { - discussionsByLine(line, leftOrRight) { - return line[leftOrRight] && line[leftOrRight].lineCode !== undefined ? - this.singleDiscussionByLineCode(line[leftOrRight].lineCode) : []; - }, - }, }; </script> @@ -73,7 +42,7 @@ export default { <table> <tbody> <template - v-for="(line, index) in parallelDiffLines" + v-for="(line, index) in diffLines" > <parallel-diff-table-row :file-hash="diffFile.fileHash" @@ -81,8 +50,6 @@ export default { :line="line" :is-bottom="index + 1 === diffLinesLength" :key="index" - :left-discussions="discussionsByLine(line, 'left')" - :right-discussions="discussionsByLine(line, 'right')" /> <parallel-diff-comment-row v-if="shouldRenderParallelCommentRow(line)" @@ -90,8 +57,6 @@ export default { :line="line" :diff-file-hash="diffFile.fileHash" :line-index="index" - :left-discussions="discussionsByLine(line, 'left')" - :right-discussions="discussionsByLine(line, 'right')" /> </template> </tbody> diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 4ab6ceb249a..027df2ec841 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -3,6 +3,7 @@ import axios from '~/lib/utils/axios_utils'; import Cookies from 'js-cookie'; import { handleLocationHash, historyPushState } from '~/lib/utils/common_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; +import { getDiffPositionByLineCode } from './utils'; import * as types from './mutation_types'; import { PARALLEL_DIFF_VIEW_TYPE, @@ -29,25 +30,53 @@ export const fetchDiffFiles = ({ state, commit }) => { .then(handleLocationHash); }; -export const startRenderDiffsQueue = ({ state, commit }) => { - const checkItem = () => { - const nextFile = state.diffFiles.find( - file => !file.renderIt && (!file.collapsed || !file.text), - ); - if (nextFile) { - requestAnimationFrame(() => { - commit(types.RENDER_FILE, nextFile); +// This is adding line discussions to the actual lines in the diff tree +// once for parallel and once for inline mode +export const assignDiscussionsToDiff = ({ state, commit }, allLineDiscussions) => { + const diffPositionByLineCode = getDiffPositionByLineCode(state.diffFiles); + + Object.values(allLineDiscussions).forEach(discussions => { + if (discussions.length > 0) { + const { fileHash } = discussions[0]; + commit(types.SET_LINE_DISCUSSIONS_FOR_FILE, { + fileHash, + discussions, + diffPositionByLineCode, }); - requestIdleCallback( - () => { - checkItem(); - }, - { timeout: 1000 }, - ); } - }; + }); +}; + +export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => { + const { fileHash, line_code } = removeDiscussion; + commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash, lineCode: line_code }); +}; + +export const startRenderDiffsQueue = ({ state, commit }) => { + const checkItem = () => + new Promise(resolve => { + const nextFile = state.diffFiles.find( + file => !file.renderIt && (!file.collapsed || !file.text), + ); + + if (nextFile) { + requestAnimationFrame(() => { + commit(types.RENDER_FILE, nextFile); + }); + requestIdleCallback( + () => { + checkItem() + .then(resolve) + .catch(() => {}); + }, + { timeout: 1000 }, + ); + } else { + resolve(); + } + }); - checkItem(); + return checkItem(); }; export const setInlineDiffViewType = ({ commit }) => { diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index 4a47646d7fa..968ba3c5e13 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -17,7 +17,10 @@ export const commitId = state => (state.commit && state.commit.id ? state.commit export const diffHasAllExpandedDiscussions = (state, getters) => diff => { const discussions = getters.getDiffFileDiscussions(diff); - return (discussions.length && discussions.every(discussion => discussion.expanded)) || false; + return ( + (discussions && discussions.length && discussions.every(discussion => discussion.expanded)) || + false + ); }; /** @@ -28,7 +31,10 @@ export const diffHasAllExpandedDiscussions = (state, getters) => diff => { export const diffHasAllCollpasedDiscussions = (state, getters) => diff => { const discussions = getters.getDiffFileDiscussions(diff); - return (discussions.length && discussions.every(discussion => !discussion.expanded)) || false; + return ( + (discussions && discussions.length && discussions.every(discussion => !discussion.expanded)) || + false + ); }; /** @@ -40,7 +46,9 @@ export const diffHasExpandedDiscussions = (state, getters) => diff => { const discussions = getters.getDiffFileDiscussions(diff); return ( - (discussions.length && discussions.find(discussion => discussion.expanded) !== undefined) || + (discussions && + discussions.length && + discussions.find(discussion => discussion.expanded) !== undefined) || false ); }; @@ -64,45 +72,38 @@ export const getDiffFileDiscussions = (state, getters, rootState, rootGetters) = discussion.diff_discussion && _.isEqual(discussion.diff_file.file_hash, diff.fileHash), ) || []; -export const singleDiscussionByLineCode = (state, getters, rootState, rootGetters) => lineCode => { - if (!lineCode || lineCode === undefined) return []; - const discussions = rootGetters.discussionsByLineCode; - return discussions[lineCode] || []; -}; - -export const shouldRenderParallelCommentRow = (state, getters) => line => { - const leftLineCode = line.left.lineCode; - const rightLineCode = line.right.lineCode; - const leftDiscussions = getters.singleDiscussionByLineCode(leftLineCode); - const rightDiscussions = getters.singleDiscussionByLineCode(rightLineCode); - const hasDiscussion = leftDiscussions.length || rightDiscussions.length; +export const shouldRenderParallelCommentRow = state => line => { + const hasDiscussion = + (line.left && line.left.discussions && line.left.discussions.length) || + (line.right && line.right.discussions && line.right.discussions.length); - const hasExpandedDiscussionOnLeft = leftDiscussions.length - ? leftDiscussions.every(discussion => discussion.expanded) - : false; - const hasExpandedDiscussionOnRight = rightDiscussions.length - ? rightDiscussions.every(discussion => discussion.expanded) - : false; + const hasExpandedDiscussionOnLeft = + line.left && line.left.discussions && line.left.discussions.length + ? line.left.discussions.every(discussion => discussion.expanded) + : false; + const hasExpandedDiscussionOnRight = + line.right && line.right.discussions && line.right.discussions.length + ? line.right.discussions.every(discussion => discussion.expanded) + : false; if (hasDiscussion && (hasExpandedDiscussionOnLeft || hasExpandedDiscussionOnRight)) { return true; } - const hasCommentFormOnLeft = state.diffLineCommentForms[leftLineCode]; - const hasCommentFormOnRight = state.diffLineCommentForms[rightLineCode]; + const hasCommentFormOnLeft = line.left && state.diffLineCommentForms[line.left.lineCode]; + const hasCommentFormOnRight = line.right && state.diffLineCommentForms[line.right.lineCode]; return hasCommentFormOnLeft || hasCommentFormOnRight; }; -export const shouldRenderInlineCommentRow = (state, getters) => line => { +export const shouldRenderInlineCommentRow = state => line => { if (state.diffLineCommentForms[line.lineCode]) return true; - const lineDiscussions = getters.singleDiscussionByLineCode(line.lineCode); - if (lineDiscussions.length === 0) { + if (!line.discussions || line.discussions.length === 0) { return false; } - return lineDiscussions.every(discussion => discussion.expanded); + return line.discussions.every(discussion => discussion.expanded); }; // prevent babel-plugin-rewire from generating an invalid default during karma∂ tests diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index c999d637d50..f61efbe6e1e 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -9,3 +9,5 @@ export const ADD_CONTEXT_LINES = 'ADD_CONTEXT_LINES'; export const ADD_COLLAPSED_DIFFS = 'ADD_COLLAPSED_DIFFS'; export const EXPAND_ALL_FILES = 'EXPAND_ALL_FILES'; export const RENDER_FILE = 'RENDER_FILE'; +export const SET_LINE_DISCUSSIONS_FOR_FILE = 'SET_LINE_DISCUSSIONS_FOR_FILE'; +export const REMOVE_LINE_DISCUSSIONS_FOR_FILE = 'REMOVE_LINE_DISCUSSIONS_FOR_FILE'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 0522e32c410..6dc5bf16c65 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -1,8 +1,13 @@ import Vue from 'vue'; -import _ from 'underscore'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { findDiffFile, addLineReferences, removeMatchLine, addContextLines } from './utils'; -import { LINES_TO_BE_RENDERED_DIRECTLY, MAX_LINES_TO_BE_RENDERED } from '../constants'; +import { + findDiffFile, + addLineReferences, + removeMatchLine, + addContextLines, + prepareDiffData, + isDiscussionApplicableToLine, +} from './utils'; import * as types from './mutation_types'; export default { @@ -17,38 +22,7 @@ export default { [types.SET_DIFF_DATA](state, data) { const diffData = convertObjectPropsToCamelCase(data, { deep: true }); - let showingLines = 0; - const filesLength = diffData.diffFiles.length; - let i; - for (i = 0; i < filesLength; i += 1) { - const file = diffData.diffFiles[i]; - if (file.parallelDiffLines) { - const linesLength = file.parallelDiffLines.length; - let u = 0; - for (u = 0; u < linesLength; u += 1) { - const line = file.parallelDiffLines[u]; - if (line.left) delete line.left.text; - if (line.right) delete line.right.text; - } - } - - if (file.highlightedDiffLines) { - const linesLength = file.highlightedDiffLines.length; - let u; - for (u = 0; u < linesLength; u += 1) { - const line = file.highlightedDiffLines[u]; - delete line.text; - } - } - - if (file.highlightedDiffLines) { - showingLines += file.parallelDiffLines.length; - } - Object.assign(file, { - renderIt: showingLines < LINES_TO_BE_RENDERED_DIRECTLY, - collapsed: file.text && showingLines > MAX_LINES_TO_BE_RENDERED, - }); - } + prepareDiffData(diffData); Object.assign(state, { ...diffData, @@ -98,19 +72,93 @@ export default { [types.ADD_COLLAPSED_DIFFS](state, { file, data }) { const normalizedData = convertObjectPropsToCamelCase(data, { deep: true }); + prepareDiffData(normalizedData); const [newFileData] = normalizedData.diffFiles.filter(f => f.fileHash === file.fileHash); - - if (newFileData) { - const index = _.findIndex(state.diffFiles, f => f.fileHash === file.fileHash); - state.diffFiles.splice(index, 1, newFileData); - } + const selectedFile = state.diffFiles.find(f => f.fileHash === file.fileHash); + Object.assign(selectedFile, { ...newFileData }); }, [types.EXPAND_ALL_FILES](state) { - // eslint-disable-next-line no-param-reassign state.diffFiles = state.diffFiles.map(file => ({ ...file, collapsed: false, })); }, + + [types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { fileHash, discussions, diffPositionByLineCode }) { + const selectedFile = state.diffFiles.find(f => f.fileHash === fileHash); + const firstDiscussion = discussions[0]; + const isDiffDiscussion = firstDiscussion.diff_discussion; + const hasLineCode = firstDiscussion.line_code; + const isResolvable = firstDiscussion.resolvable; + const diffPosition = diffPositionByLineCode[firstDiscussion.line_code]; + + if ( + selectedFile && + isDiffDiscussion && + hasLineCode && + isResolvable && + diffPosition && + isDiscussionApplicableToLine(firstDiscussion, diffPosition) + ) { + const targetLine = selectedFile.parallelDiffLines.find( + line => + (line.left && line.left.lineCode === firstDiscussion.line_code) || + (line.right && line.right.lineCode === firstDiscussion.line_code), + ); + if (targetLine) { + if (targetLine.left && targetLine.left.lineCode === firstDiscussion.line_code) { + Object.assign(targetLine.left, { + discussions, + }); + } else { + Object.assign(targetLine.right, { + discussions, + }); + } + } + + if (selectedFile.highlightedDiffLines) { + const targetInlineLine = selectedFile.highlightedDiffLines.find( + line => line.lineCode === firstDiscussion.line_code, + ); + + if (targetInlineLine) { + Object.assign(targetInlineLine, { + discussions, + }); + } + } + } + }, + + [types.REMOVE_LINE_DISCUSSIONS_FOR_FILE](state, { fileHash, lineCode }) { + const selectedFile = state.diffFiles.find(f => f.fileHash === fileHash); + if (selectedFile) { + const targetLine = selectedFile.parallelDiffLines.find( + line => + (line.left && line.left.lineCode === lineCode) || + (line.right && line.right.lineCode === lineCode), + ); + if (targetLine) { + const side = targetLine.left && targetLine.left.lineCode === lineCode ? 'left' : 'right'; + + Object.assign(targetLine[side], { + discussions: [], + }); + } + + if (selectedFile.highlightedDiffLines) { + const targetInlineLine = selectedFile.highlightedDiffLines.find( + line => line.lineCode === lineCode, + ); + + if (targetInlineLine) { + Object.assign(targetInlineLine, { + discussions: [], + }); + } + } + } + }, }; diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 82082ac508a..b7e52a8f37f 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -8,6 +8,8 @@ import { NEW_LINE_TYPE, OLD_LINE_TYPE, MATCH_LINE_TYPE, + LINES_TO_BE_RENDERED_DIRECTLY, + MAX_LINES_TO_BE_RENDERED, } from '../constants'; export function findDiffFile(files, hash) { @@ -161,6 +163,11 @@ export function addContextLines(options) { * @returns {Object} */ export function trimFirstCharOfLineContent(line = {}) { + // eslint-disable-next-line no-param-reassign + delete line.text; + // eslint-disable-next-line no-param-reassign + line.discussions = []; + const parsedLine = Object.assign({}, line); if (line.richText) { @@ -174,7 +181,44 @@ export function trimFirstCharOfLineContent(line = {}) { return parsedLine; } -export function getDiffRefsByLineCode(diffFiles) { +// This prepares and optimizes the incoming diff data from the server +// by setting up incremental rendering and removing unneeded data +export function prepareDiffData(diffData) { + const filesLength = diffData.diffFiles.length; + let showingLines = 0; + for (let i = 0; i < filesLength; i += 1) { + const file = diffData.diffFiles[i]; + + if (file.parallelDiffLines) { + const linesLength = file.parallelDiffLines.length; + for (let u = 0; u < linesLength; u += 1) { + const line = file.parallelDiffLines[u]; + if (line.left) { + line.left = trimFirstCharOfLineContent(line.left); + } + if (line.right) { + line.right = trimFirstCharOfLineContent(line.right); + } + } + } + + if (file.highlightedDiffLines) { + const linesLength = file.highlightedDiffLines.length; + for (let u = 0; u < linesLength; u += 1) { + const line = file.highlightedDiffLines[u]; + Object.assign(line, { ...trimFirstCharOfLineContent(line) }); + } + showingLines += file.parallelDiffLines.length; + } + + Object.assign(file, { + renderIt: showingLines < LINES_TO_BE_RENDERED_DIRECTLY, + collapsed: file.text && showingLines > MAX_LINES_TO_BE_RENDERED, + }); + } +} + +export function getDiffPositionByLineCode(diffFiles) { return diffFiles.reduce((acc, diffFile) => { const { baseSha, headSha, startSha } = diffFile.diffRefs; const { newPath, oldPath } = diffFile; @@ -194,3 +238,12 @@ export function getDiffRefsByLineCode(diffFiles) { return acc; }, {}); } + +// This method will check whether the discussion is still applicable +// to the diff line in question regarding different versions of the MR +export function isDiscussionApplicableToLine(discussion, diffPosition) { + const originalRefs = convertObjectPropsToCamelCase(discussion.original_position.formatter); + const refs = convertObjectPropsToCamelCase(discussion.position.formatter); + + return _.isEqual(refs, diffPosition) || _.isEqual(originalRefs, diffPosition); +} diff --git a/app/assets/javascripts/clusters/components/gcp_signup_offer.js b/app/assets/javascripts/dismissable_callout.js index 8bc20a1c09f..5185b019376 100644 --- a/app/assets/javascripts/clusters/components/gcp_signup_offer.js +++ b/app/assets/javascripts/dismissable_callout.js @@ -3,8 +3,8 @@ import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import Flash from '~/flash'; -export default function gcpSignupOffer() { - const alertEl = document.querySelector('.gcp-signup-offer'); +export default function initDismissableCallout(alertSelector) { + const alertEl = document.querySelector(alertSelector); if (!alertEl) { return; } diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 5528ad9f38d..ff969bb94a4 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -7,6 +7,19 @@ import axios from './lib/utils/axios_utils'; Dropzone.autoDiscover = false; +/** + * Return the error message string from the given response. + * + * @param {String|Object} res + */ +function getErrorMessage(res) { + if (!res || _.isString(res)) { + return res; + } + + return res.message; +} + export default function dropzoneInput(form) { const divHover = '<div class="div-dropzone-hover"></div>'; const iconPaperclip = '<i class="fa fa-paperclip div-dropzone-icon"></i>'; @@ -18,7 +31,7 @@ export default function dropzoneInput(form) { const $uploadingErrorContainer = form.find('.uploading-error-container'); const $uploadingErrorMessage = form.find('.uploading-error-message'); const $uploadingProgressContainer = form.find('.uploading-progress-container'); - const uploadsPath = window.uploads_path || null; + const uploadsPath = form.data('uploads-path') || window.uploads_path || null; const maxFileSize = gon.max_file_size || 10; const formTextarea = form.find('.js-gfm-input'); let handlePaste; @@ -42,7 +55,7 @@ export default function dropzoneInput(form) { if (!uploadsPath) { $formDropzone.addClass('js-invalid-dropzone'); - return; + return null; } const dropzone = $formDropzone.dropzone({ @@ -84,9 +97,7 @@ export default function dropzoneInput(form) { // xhr object (xhr.responseText is error message). // On error we hide the 'Attach' and 'Cancel' buttons // and show an error. - - // If there's xhr error message, let's show it instead of dropzone's one. - const message = xhr ? xhr.responseText : errorMessage; + const message = getErrorMessage(errorMessage || xhr.responseText); $uploadingErrorContainer.removeClass('hide'); $uploadingErrorMessage.html(message); @@ -274,4 +285,6 @@ export default function dropzoneInput(form) { $(this).closest('.gfm-form').find('.div-dropzone').click(); formTextarea.focus(); }); + + return Dropzone.forElement($formDropzone.get(0)); } diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue index 9aa224fa407..9de851c9409 100644 --- a/app/assets/javascripts/environments/components/container.vue +++ b/app/assets/javascripts/environments/components/container.vue @@ -1,12 +1,10 @@ <script> - import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import tablePagination from '../../vue_shared/components/table_pagination.vue'; import environmentTable from '../components/environments_table.vue'; export default { components: { environmentTable, - loadingIcon, tablePagination, }, props: { @@ -42,11 +40,11 @@ <template> <div class="environments-container"> - <loading-icon + <gl-loading-icon v-if="isLoading" + :size="3" class="prepend-top-default" label="Loading environments" - size="3" /> <slot name="emptyState"></slot> diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index 63d83e307ee..e1f9248bc4c 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -1,7 +1,6 @@ <script> import Icon from '~/vue_shared/components/icon.vue'; import eventHub from '../event_hub'; -import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import tooltip from '../../vue_shared/directives/tooltip'; export default { @@ -9,7 +8,6 @@ export default { tooltip, }, components: { - loadingIcon, Icon, }, props: { @@ -67,7 +65,7 @@ export default { aria-hidden="true" > </i> - <loading-icon v-if="isLoading" /> + <gl-loading-icon v-if="isLoading" /> </span> </button> diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue index 4deeef4beb9..efbf88d0f11 100644 --- a/app/assets/javascripts/environments/components/environment_rollback.vue +++ b/app/assets/javascripts/environments/components/environment_rollback.vue @@ -9,12 +9,10 @@ import { s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; import eventHub from '../event_hub'; -import LoadingIcon from '../../vue_shared/components/loading_icon.vue'; export default { components: { Icon, - LoadingIcon, }, directives: { @@ -70,6 +68,6 @@ export default { v-else name="redo"/> - <loading-icon v-if="isLoading" /> + <gl-loading-icon v-if="isLoading" /> </button> </template> diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 016e9f7c7b3..a9d9d768c06 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -2,13 +2,11 @@ /** * Render environments table. */ -import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import environmentItem from './environment_item.vue'; export default { components: { environmentItem, - loadingIcon, }, props: { @@ -97,7 +95,7 @@ export default { <div v-if="model.isLoadingFolderContent" :key="`loading-item-${i}`"> - <loading-icon size="2" /> + <gl-loading-icon :size="2" /> </div> <template v-else> diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js index d88624f7f8d..d71964612c5 100644 --- a/app/assets/javascripts/environments/mixins/environments_mixin.js +++ b/app/assets/javascripts/environments/mixins/environments_mixin.js @@ -13,7 +13,6 @@ import eventHub from '../event_hub'; import EnvironmentsStore from '../stores/environments_store'; import EnvironmentsService from '../services/environments_service'; -import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import tablePagination from '../../vue_shared/components/table_pagination.vue'; import environmentTable from '../components/environments_table.vue'; import tabs from '../../vue_shared/components/navigation_tabs.vue'; @@ -24,7 +23,6 @@ export default { components: { environmentTable, container, - loadingIcon, tabs, tablePagination, }, diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js index 8b4f3b05ee7..f820f0dc3f0 100644 --- a/app/assets/javascripts/fly_out_nav.js +++ b/app/assets/javascripts/fly_out_nav.js @@ -65,8 +65,8 @@ export const hideMenu = (el) => { const parentEl = el.parentNode; - el.style.display = ''; // eslint-disable-line no-param-reassign - el.style.transform = ''; // eslint-disable-line no-param-reassign + el.style.display = ''; + el.style.transform = ''; el.classList.remove(IS_ABOVE_CLASS); parentEl.classList.remove(IS_OVER_CLASS); parentEl.classList.remove(IS_SHOWING_FLY_OUT_CLASS); diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue index 2f030de8967..70a8838b772 100644 --- a/app/assets/javascripts/frequent_items/components/app.vue +++ b/app/assets/javascripts/frequent_items/components/app.vue @@ -1,6 +1,5 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; -import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; import AccessorUtilities from '~/lib/utils/accessor'; import eventHub from '../event_hub'; import store from '../store/'; @@ -13,7 +12,6 @@ import frequentItemsMixin from './frequent_items_mixin'; export default { store, components: { - LoadingIcon, FrequentItemsSearchInput, FrequentItemsList, }, @@ -98,11 +96,11 @@ export default { <frequent-items-search-input :namespace="namespace" /> - <loading-icon + <gl-loading-icon v-if="isLoadingItems" :label="translations.loadingMessage" + :size="2" class="loading-animation prepend-top-20" - size="2" /> <div v-if="!isLoadingItems && !hasSearchQuery" diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index b0765747a36..a032f291546 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -2,23 +2,32 @@ /* global Flash */ import $ from 'jquery'; -import { s__ } from '~/locale'; -import loadingIcon from '~/vue_shared/components/loading_icon.vue'; +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 eventHub from '../event_hub'; -import { COMMON_STR } from '../constants'; +import { COMMON_STR, CONTENT_LIST_CLASS } from '../constants'; import groupsComponent from './groups.vue'; export default { components: { - loadingIcon, DeprecatedModal, groupsComponent, }, props: { + action: { + type: String, + required: false, + default: '', + }, + containerId: { + type: String, + required: false, + default: '', + }, store: { type: Object, required: true, @@ -56,31 +65,28 @@ export default { ? COMMON_STR.GROUP_SEARCH_EMPTY : COMMON_STR.GROUP_PROJECT_SEARCH_EMPTY; - eventHub.$on('fetchPage', this.fetchPage); - eventHub.$on('toggleChildren', this.toggleChildren); - eventHub.$on('showLeaveGroupModal', this.showLeaveGroupModal); - eventHub.$on('updatePagination', this.updatePagination); - eventHub.$on('updateGroups', this.updateGroups); + eventHub.$on(`${this.action}fetchPage`, this.fetchPage); + eventHub.$on(`${this.action}toggleChildren`, this.toggleChildren); + eventHub.$on(`${this.action}showLeaveGroupModal`, this.showLeaveGroupModal); + eventHub.$on(`${this.action}updatePagination`, this.updatePagination); + eventHub.$on(`${this.action}updateGroups`, this.updateGroups); }, mounted() { this.fetchAllGroups(); + + if (this.containerId) { + this.containerEl = document.getElementById(this.containerId); + } }, beforeDestroy() { - eventHub.$off('fetchPage', this.fetchPage); - eventHub.$off('toggleChildren', this.toggleChildren); - eventHub.$off('showLeaveGroupModal', this.showLeaveGroupModal); - eventHub.$off('updatePagination', this.updatePagination); - eventHub.$off('updateGroups', this.updateGroups); + eventHub.$off(`${this.action}fetchPage`, this.fetchPage); + eventHub.$off(`${this.action}toggleChildren`, this.toggleChildren); + eventHub.$off(`${this.action}showLeaveGroupModal`, this.showLeaveGroupModal); + eventHub.$off(`${this.action}updatePagination`, this.updatePagination); + eventHub.$off(`${this.action}updateGroups`, this.updateGroups); }, methods: { - fetchGroups({ - parentId, - page, - filterGroupsBy, - sortBy, - archived, - updatePagination, - }) { + fetchGroups({ parentId, page, filterGroupsBy, sortBy, archived, updatePagination }) { return this.service .getGroups(parentId, page, filterGroupsBy, sortBy, archived) .then(res => { @@ -165,13 +171,13 @@ export default { } }, showLeaveGroupModal(group, parentGroup) { + const { fullName } = group; this.targetGroup = group; this.targetParentGroup = parentGroup; this.showModal = true; - this.groupLeaveConfirmationMessage = s__( - `GroupsTree|Are you sure you want to leave the "${ - group.fullName - }" group?`, + this.groupLeaveConfirmationMessage = sprintf( + s__('GroupsTree|Are you sure you want to leave the "%{fullName}" group?'), + { fullName }, ); }, hideLeaveGroupModal() { @@ -197,16 +203,35 @@ export default { this.targetGroup.isBeingRemoved = false; }); }, + showEmptyState() { + const { containerEl } = this; + const contentListEl = containerEl.querySelector(CONTENT_LIST_CLASS); + const emptyStateEl = containerEl.querySelector('.empty-state'); + + if (contentListEl) { + contentListEl.remove(); + } + + if (emptyStateEl) { + emptyStateEl.classList.remove(HIDDEN_CLASS); + } + }, updatePagination(headers) { this.store.setPaginationInfo(headers); }, updateGroups(groups, fromSearch) { - this.isSearchEmpty = groups ? groups.length === 0 : false; + const hasGroups = groups && groups.length > 0; + this.isSearchEmpty = !hasGroups; + if (fromSearch) { this.store.setSearchedGroups(groups); } else { this.store.setGroups(groups); } + + if (this.action && !hasGroups && !fromSearch) { + this.showEmptyState(); + } }, }, }; @@ -214,11 +239,11 @@ export default { <template> <div> - <loading-icon + <gl-loading-icon v-if="isLoading" :label="s__('GroupsTree|Loading groups')" + :size="2" class="loading-animation prepend-top-20" - size="2" /> <groups-component v-if="!isLoading" @@ -226,6 +251,7 @@ export default { :search-empty="isSearchEmpty" :search-empty-message="searchEmptyMessage" :page-info="pageInfo" + :action="action" /> <deprecated-modal v-show="showModal" diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue index 647c9d0046d..bcc7a638346 100644 --- a/app/assets/javascripts/groups/components/group_folder.vue +++ b/app/assets/javascripts/groups/components/group_folder.vue @@ -11,8 +11,12 @@ export default { }, groups: { type: Array, + required: true, + }, + action: { + type: String, required: false, - default: () => ([]), + default: '', }, }, computed: { @@ -37,6 +41,7 @@ export default { :key="index" :group="group" :parent-group="parentGroup" + :action="action" /> <li v-if="hasMoreChildren" diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 2b9e2a929fc..44d6fa26914 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -30,6 +30,11 @@ export default { type: Object, required: true, }, + action: { + type: String, + required: false, + default: '', + }, }, computed: { groupDomId() { @@ -56,10 +61,12 @@ export default { methods: { onClickRowGroup(e) { const NO_EXPAND_CLS = 'no-expand'; - if (!(e.target.classList.contains(NO_EXPAND_CLS) || - e.target.parentElement.classList.contains(NO_EXPAND_CLS))) { + const targetClasses = e.target.classList; + const parentElClasses = e.target.parentElement.classList; + + if (!(targetClasses.contains(NO_EXPAND_CLS) || parentElClasses.contains(NO_EXPAND_CLS))) { if (this.hasChildren) { - eventHub.$emit('toggleChildren', this.group); + eventHub.$emit(`${this.action}toggleChildren`, this.group); } else { visitUrl(this.group.relativePath); } @@ -93,7 +100,7 @@ export default { </div> <div :class="{ 'content-loading': group.isChildrenLoading }" - class="avatar-container s24 d-none d-sm-block" + class="avatar-container s24 d-none d-sm-flex" > <a :href="group.relativePath" @@ -158,6 +165,7 @@ export default { v-if="group.isOpen && hasChildren" :parent-group="group" :groups="group.children" + :action="action" /> </li> </template> diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue index 73ae928b0d9..81b2e5ea37b 100644 --- a/app/assets/javascripts/groups/components/groups.vue +++ b/app/assets/javascripts/groups/components/groups.vue @@ -1,39 +1,44 @@ <script> - import tablePagination from '~/vue_shared/components/table_pagination.vue'; - import eventHub from '../event_hub'; - import { getParameterByName } from '../../lib/utils/common_utils'; +import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; +import eventHub from '../event_hub'; +import { getParameterByName } from '../../lib/utils/common_utils'; - export default { - components: { - tablePagination, +export default { + components: { + PaginationLinks, + }, + props: { + groups: { + type: Array, + required: true, }, - props: { - groups: { - type: Array, - required: true, - }, - pageInfo: { - type: Object, - required: true, - }, - searchEmpty: { - type: Boolean, - required: true, - }, - searchEmptyMessage: { - type: String, - required: true, - }, + pageInfo: { + type: Object, + required: true, }, - methods: { - change(page) { - const filterGroupsParam = getParameterByName('filter_groups'); - const sortParam = getParameterByName('sort'); - const archivedParam = getParameterByName('archived'); - eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam, archivedParam); - }, + searchEmpty: { + type: Boolean, + required: true, }, - }; + searchEmptyMessage: { + type: String, + required: true, + }, + action: { + type: String, + required: false, + default: '', + }, + }, + methods: { + change(page) { + const filterGroupsParam = getParameterByName('filter_groups'); + const sortParam = getParameterByName('sort'); + const archivedParam = getParameterByName('archived'); + eventHub.$emit(`${this.action}fetchPage`, page, filterGroupsParam, sortParam, archivedParam); + }, + }, +}; </script> <template> @@ -44,14 +49,18 @@ > {{ searchEmptyMessage }} </div> - <group-folder - v-if="!searchEmpty" - :groups="groups" - /> - <table-pagination - v-if="!searchEmpty" - :change="change" - :page-info="pageInfo" - /> + <template + v-else + > + <group-folder + :groups="groups" + :action="action" + /> + <pagination-links + :change="change" + :page-info="pageInfo" + class="d-flex justify-content-center prepend-top-default" + /> + </template> </div> </template> diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue index 24eec4901ec..6e700b8bf8a 100644 --- a/app/assets/javascripts/groups/components/item_actions.vue +++ b/app/assets/javascripts/groups/components/item_actions.vue @@ -21,6 +21,11 @@ export default { type: Object, required: true, }, + action: { + type: String, + required: false, + default: '', + }, }, computed: { leaveBtnTitle() { @@ -32,7 +37,7 @@ export default { }, methods: { onLeaveGroup() { - eventHub.$emit('showLeaveGroupModal', this.group, this.parentGroup); + eventHub.$emit(`${this.action}showLeaveGroupModal`, this.group, this.parentGroup); }, }, }; diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js index b8baed682f5..9c246cf3ba6 100644 --- a/app/assets/javascripts/groups/constants.js +++ b/app/assets/javascripts/groups/constants.js @@ -2,13 +2,23 @@ import { __, s__ } from '../locale'; export const MAX_CHILDREN_COUNT = 20; +export const ACTIVE_TAB_SUBGROUPS_AND_PROJECTS = 'subgroups_and_projects'; +export const ACTIVE_TAB_SHARED = 'shared'; +export const ACTIVE_TAB_ARCHIVED = 'archived'; + +export const GROUPS_LIST_HOLDER_CLASS = '.js-groups-list-holder'; +export const GROUPS_FILTER_FORM_CLASS = '.js-group-filter-form'; +export const CONTENT_LIST_CLASS = '.content-list'; + export const COMMON_STR = { FAILURE: __('An error occurred. Please try again.'), - LEAVE_FORBIDDEN: s__('GroupsTree|Failed to leave the group. Please make sure you are not the only owner.'), + LEAVE_FORBIDDEN: s__( + 'GroupsTree|Failed to leave the group. Please make sure you are not the only owner.', + ), LEAVE_BTN_TITLE: s__('GroupsTree|Leave this group'), EDIT_BTN_TITLE: s__('GroupsTree|Edit group'), - GROUP_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups matched your search'), - GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups or projects matched your search'), + GROUP_SEARCH_EMPTY: s__('GroupsTree|No groups matched your search'), + GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|No groups or projects matched your search'), }; export const ITEM_TYPE = { @@ -17,8 +27,12 @@ export const ITEM_TYPE = { }; export const GROUP_VISIBILITY_TYPE = { - public: __('Public - The group and any public projects can be viewed without any authentication.'), - internal: __('Internal - The group and any internal projects can be viewed by any logged in user.'), + public: __( + 'Public - The group and any public projects can be viewed without any authentication.', + ), + internal: __( + 'Internal - The group and any internal projects can be viewed by any logged in user.', + ), private: __('Private - The group and its projects can only be viewed by members.'), }; diff --git a/app/assets/javascripts/groups/groups_filterable_list.js b/app/assets/javascripts/groups/groups_filterable_list.js index e6db1746487..693519729ac 100644 --- a/app/assets/javascripts/groups/groups_filterable_list.js +++ b/app/assets/javascripts/groups/groups_filterable_list.js @@ -4,13 +4,23 @@ import eventHub from './event_hub'; import { normalizeHeaders, getParameterByName } from '../lib/utils/common_utils'; export default class GroupFilterableList extends FilterableList { - constructor({ form, filter, holder, filterEndpoint, pagePath, dropdownSel, filterInputField }) { + constructor({ + form, + filter, + holder, + filterEndpoint, + pagePath, + dropdownSel, + filterInputField, + action, + }) { super(form, filter, holder, filterInputField); this.form = form; this.filterEndpoint = filterEndpoint; this.pagePath = pagePath; this.filterInputField = filterInputField; this.$dropdown = $(dropdownSel); + this.action = action; } getFilterEndpoint() { @@ -20,15 +30,16 @@ export default class GroupFilterableList extends FilterableList { getPagePath(queryData) { const params = queryData ? $.param(queryData) : ''; const queryString = params ? `?${params}` : ''; - return `${this.pagePath}${queryString}`; + const path = this.pagePath || window.location.pathname; + return `${path}${queryString}`; } bindEvents() { super.bindEvents(); - this.onFilterOptionClikWrapper = this.onOptionClick.bind(this); + this.onFilterOptionClickWrapper = this.onOptionClick.bind(this); - this.$dropdown.on('click', 'a', this.onFilterOptionClikWrapper); + this.$dropdown.on('click', 'a', this.onFilterOptionClickWrapper); } onFilterInput() { @@ -53,7 +64,12 @@ export default class GroupFilterableList extends FilterableList { } setDefaultFilterOption() { - const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu li.js-filter-sort-order a').first().text()); + const defaultOption = $.trim( + this.$dropdown + .find('.dropdown-menu li.js-filter-sort-order a') + .first() + .text(), + ); this.$dropdown.find('.dropdown-label').text(defaultOption); } @@ -65,11 +81,19 @@ export default class GroupFilterableList extends FilterableList { // Get type of option selected from dropdown const currentTargetClassList = e.currentTarget.parentElement.classList; const isOptionFilterBySort = currentTargetClassList.contains('js-filter-sort-order'); - const isOptionFilterByArchivedProjects = currentTargetClassList.contains('js-filter-archived-projects'); + const isOptionFilterByArchivedProjects = currentTargetClassList.contains( + 'js-filter-archived-projects', + ); // Get option query param, also preserve currently applied query param - const sortParam = getParameterByName('sort', isOptionFilterBySort ? e.currentTarget.href : window.location.href); - const archivedParam = getParameterByName('archived', isOptionFilterByArchivedProjects ? e.currentTarget.href : window.location.href); + const sortParam = getParameterByName( + 'sort', + isOptionFilterBySort ? e.currentTarget.href : window.location.href, + ); + const archivedParam = getParameterByName( + 'archived', + isOptionFilterByArchivedProjects ? e.currentTarget.href : window.location.href, + ); if (sortParam) { queryData.sort = sortParam; @@ -86,7 +110,9 @@ export default class GroupFilterableList extends FilterableList { this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text)); this.$dropdown.find('.dropdown-menu li.js-filter-sort-order a').removeClass('is-active'); } else if (isOptionFilterByArchivedProjects) { - this.$dropdown.find('.dropdown-menu li.js-filter-archived-projects a').removeClass('is-active'); + this.$dropdown + .find('.dropdown-menu li.js-filter-archived-projects a') + .removeClass('is-active'); } $(e.target).addClass('is-active'); @@ -98,11 +124,19 @@ export default class GroupFilterableList extends FilterableList { onFilterSuccess(res, queryData) { const currentPath = this.getPagePath(queryData); - window.history.replaceState({ - page: currentPath, - }, document.title, currentPath); - - eventHub.$emit('updateGroups', res.data, Object.prototype.hasOwnProperty.call(queryData, this.filterInputField)); - eventHub.$emit('updatePagination', normalizeHeaders(res.headers)); + window.history.replaceState( + { + page: currentPath, + }, + document.title, + currentPath, + ); + + eventHub.$emit( + `${this.action}updateGroups`, + res.data, + Object.prototype.hasOwnProperty.call(queryData, this.filterInputField), + ); + eventHub.$emit(`${this.action}updatePagination`, normalizeHeaders(res.headers)); } } diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js index 83a9008a94b..0f68f05b523 100644 --- a/app/assets/javascripts/groups/index.js +++ b/app/assets/javascripts/groups/index.js @@ -7,18 +7,26 @@ import GroupsService from './service/groups_service'; import groupsApp from './components/app.vue'; import groupFolderComponent from './components/group_folder.vue'; import groupItemComponent from './components/group_item.vue'; +import { GROUPS_LIST_HOLDER_CLASS, CONTENT_LIST_CLASS } from './constants'; Vue.use(Translate); -export default () => { - const el = document.getElementById('js-groups-tree'); +export default (containerId = 'js-groups-tree', endpoint, action = '') => { + const containerEl = document.getElementById(containerId); + let dataEl; // Don't do anything if element doesn't exist (No groups) // This is for when the user enters directly to the page via URL - if (!el) { + if (!containerEl) { return; } + const el = action ? containerEl.querySelector(GROUPS_LIST_HOLDER_CLASS) : containerEl; + + if (action) { + dataEl = containerEl.querySelector(CONTENT_LIST_CLASS); + } + Vue.component('group-folder', groupFolderComponent); Vue.component('group-item', groupItemComponent); @@ -29,20 +37,26 @@ export default () => { groupsApp, }, data() { - const { dataset } = this.$options.el; + const { dataset } = dataEl || this.$options.el; const hideProjects = dataset.hideProjects === 'true'; + const service = new GroupsService(endpoint || dataset.endpoint); const store = new GroupsStore(hideProjects); - const service = new GroupsService(dataset.endpoint); return { + action, store, service, hideProjects, loading: true, + containerId, }; }, beforeMount() { - const { dataset } = this.$options.el; + if (this.action) { + return; + } + + const { dataset } = dataEl || this.$options.el; let groupFilterList = null; const form = document.querySelector(dataset.formSel); const filter = document.querySelector(dataset.filterSel); @@ -52,10 +66,11 @@ export default () => { form, filter, holder, - filterEndpoint: dataset.endpoint, + filterEndpoint: endpoint || dataset.endpoint, pagePath: dataset.path, dropdownSel: dataset.dropdownSel, filterInputField: 'filter', + action: this.action, }; groupFilterList = new GroupFilterableList(opts); @@ -64,9 +79,11 @@ export default () => { render(createElement) { return createElement('groups-app', { props: { + action: this.action, store: this.store, service: this.service, hideProjects: this.hideProjects, + containerId: this.containerId, }, }); }, diff --git a/app/assets/javascripts/ide/components/branches/search_list.vue b/app/assets/javascripts/ide/components/branches/search_list.vue index 6db7b9d6b0e..bf0ff6e35ec 100644 --- a/app/assets/javascripts/ide/components/branches/search_list.vue +++ b/app/assets/javascripts/ide/components/branches/search_list.vue @@ -1,13 +1,11 @@ <script> import { mapActions, mapState } from 'vuex'; import _ from 'underscore'; -import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; import Icon from '~/vue_shared/components/icon.vue'; import Item from './item.vue'; export default { components: { - LoadingIcon, Item, Icon, }, @@ -76,10 +74,10 @@ export default { </div> </div> <div class="dropdown-content ide-merge-requests-dropdown-content d-flex"> - <loading-icon + <gl-loading-icon v-if="isLoading" + :size="2" class="mt-3 mb-3 align-self-center ml-auto mr-auto" - size="2" /> <ul v-else diff --git a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue new file mode 100644 index 00000000000..c3ca147e850 --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue @@ -0,0 +1,78 @@ +<script> +import $ from 'jquery'; +import { mapActions } from 'vuex'; +import { __ } from '~/locale'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; +import ChangedFileIcon from '../changed_file_icon.vue'; + +export default { + components: { + FileIcon, + ChangedFileIcon, + }, + props: { + activeFile: { + type: Object, + required: true, + }, + }, + computed: { + activeButtonText() { + return this.activeFile.staged ? __('Unstage') : __('Stage'); + }, + isStaged() { + return !this.activeFile.changed && this.activeFile.staged; + }, + }, + methods: { + ...mapActions(['stageChange', 'unstageChange']), + actionButtonClicked() { + if (this.activeFile.staged) { + this.unstageChange(this.activeFile.path); + } else { + this.stageChange(this.activeFile.path); + } + }, + showDiscardModal() { + $(document.getElementById(`discard-file-${this.activeFile.path}`)).modal('show'); + }, + }, +}; +</script> + +<template> + <div class="d-flex ide-commit-editor-header align-items-center"> + <file-icon + :file-name="activeFile.name" + :size="16" + class="mr-2" + /> + <strong class="mr-2"> + {{ activeFile.path }} + </strong> + <changed-file-icon + :file="activeFile" + /> + <div class="ml-auto"> + <button + v-if="!isStaged" + type="button" + class="btn btn-remove btn-inverted append-right-8" + @click="showDiscardModal" + > + {{ __('Discard') }} + </button> + <button + :class="{ + 'btn-success': !isStaged, + 'btn-warning': isStaged + }" + type="button" + class="btn btn-inverted" + @click="actionButtonClicked" + > + {{ activeButtonText }} + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue index d0fb0e3d99e..3fdd35ad228 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -1,7 +1,9 @@ <script> +import $ from 'jquery'; import { mapActions } from 'vuex'; import { __, sprintf } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; +import GlModal from '~/vue_shared/components/gl_modal.vue'; import tooltip from '~/vue_shared/directives/tooltip'; import ListItem from './list_item.vue'; @@ -9,6 +11,7 @@ export default { components: { Icon, ListItem, + GlModal, }, directives: { tooltip, @@ -56,6 +59,11 @@ export default { type: String, required: true, }, + emptyStateText: { + type: String, + required: false, + default: __('No changes'), + }, }, computed: { titleText() { @@ -68,11 +76,19 @@ export default { }, }, methods: { - ...mapActions(['stageAllChanges', 'unstageAllChanges']), + ...mapActions(['stageAllChanges', 'unstageAllChanges', 'discardAllChanges']), actionBtnClicked() { this[this.action](); + + $(this.$refs.actionBtn).tooltip('hide'); + }, + openDiscardModal() { + $('#discard-all-changes').modal('show'); }, }, + discardModalText: __( + "You will loose all the unstaged changes you've made in this project. This action cannot be undone.", + ), }; </script> @@ -81,27 +97,32 @@ export default { class="ide-commit-list-container" > <header - class="multi-file-commit-panel-header" + class="multi-file-commit-panel-header d-flex mb-0" > <div - class="multi-file-commit-panel-header-title" + class="d-flex align-items-center flex-fill" > <icon v-once :name="iconName" :size="18" + class="append-right-8" /> - {{ titleText }} + <strong> + {{ titleText }} + </strong> <div class="d-flex ml-auto"> <button v-tooltip - v-show="filesLength" + ref="actionBtn" + :title="actionBtnText" + :aria-label="actionBtnText" + :disabled="!filesLength" :class="{ - 'd-flex': filesLength + 'disabled-content': !filesLength }" - :title="actionBtnText" type="button" - class="btn btn-default ide-staged-action-btn p-0 order-1 align-items-center" + class="d-flex ide-staged-action-btn p-0 border-0 align-items-center" data-placement="bottom" data-container="body" data-boundary="viewport" @@ -109,18 +130,32 @@ export default { > <icon :name="actionBtnIcon" - :size="12" + :size="16" class="ml-auto mr-auto" /> </button> - <span + <button + v-tooltip + v-if="!stagedList" + :title="__('Discard all changes')" + :aria-label="__('Discard all changes')" + :disabled="!filesLength" :class="{ - 'rounded-right': !filesLength + 'disabled-content': !filesLength }" - class="ide-commit-file-count order-0 rounded-left text-center" + type="button" + class="d-flex ide-staged-action-btn p-0 border-0 align-items-center" + data-placement="bottom" + data-container="body" + data-boundary="viewport" + @click="openDiscardModal" > - {{ filesLength }} - </span> + <icon + :size="16" + name="remove-all" + class="ml-auto mr-auto" + /> + </button> </div> </div> </header> @@ -143,9 +178,19 @@ export default { </ul> <p v-else - class="multi-file-commit-list form-text text-muted" + class="multi-file-commit-list form-text text-muted text-center" > - {{ __('No changes') }} + {{ emptyStateText }} </p> + <gl-modal + v-if="!stagedList" + id="discard-all-changes" + :footer-primary-button-text="__('Discard all changes')" + :header-title-text="__('Discard all unstaged changes?')" + footer-primary-button-variant="danger" + @submit="discardAllChanges" + > + {{ $options.discardModalText }} + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue index 391004dcd3c..10c78a80302 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -2,6 +2,7 @@ import { mapActions } from 'vuex'; import tooltip from '~/vue_shared/directives/tooltip'; import Icon from '~/vue_shared/components/icon.vue'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; import StageButton from './stage_button.vue'; import UnstageButton from './unstage_button.vue'; import { viewerTypes } from '../../constants'; @@ -12,6 +13,7 @@ export default { Icon, StageButton, UnstageButton, + FileIcon, }, directives: { tooltip, @@ -48,7 +50,7 @@ export default { return `${getCommitIconMap(this.file).icon}${suffix}`; }, iconClass() { - return `${getCommitIconMap(this.file).class} append-right-8`; + return `${getCommitIconMap(this.file).class} ml-auto mr-auto`; }, fullKey() { return `${this.keyPrefix}-${this.file.key}`; @@ -105,17 +107,24 @@ export default { @click="openFileInEditor" > <span class="multi-file-commit-list-file-path d-flex align-items-center"> - <icon - :name="iconName" - :size="16" - :css-classes="iconClass" + <file-icon + :file-name="file.name" + class="append-right-8" />{{ file.name }} </span> + <div class="ml-auto d-flex align-items-center"> + <div class="d-flex align-items-center ide-commit-list-changed-icon"> + <icon + :name="iconName" + :size="16" + :css-classes="iconClass" + /> + </div> + <component + :is="actionComponent" + :path="file.path" + /> + </div> </div> - <component - :is="actionComponent" - :path="file.path" - class="d-flex position-absolute" - /> </div> </template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue index e6044401c9f..8a1836a5c92 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue @@ -1,11 +1,15 @@ <script> +import $ from 'jquery'; import { mapActions } from 'vuex'; +import { sprintf, __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; +import GlModal from '~/vue_shared/components/gl_modal.vue'; export default { components: { Icon, + GlModal, }, directives: { tooltip, @@ -16,8 +20,22 @@ export default { required: true, }, }, + computed: { + modalId() { + return `discard-file-${this.path}`; + }, + modalTitle() { + return sprintf( + __('Discard changes to %{path}?'), + { path: this.path }, + ); + }, + }, methods: { ...mapActions(['stageChange', 'discardFileChanges']), + showDiscardModal() { + $(document.getElementById(this.modalId)).modal('show'); + }, }, }; </script> @@ -25,51 +43,50 @@ export default { <template> <div v-once - class="multi-file-discard-btn dropdown" + class="multi-file-discard-btn d-flex" > <button v-tooltip :aria-label="__('Stage changes')" :title="__('Stage changes')" type="button" - class="btn btn-blank append-right-5 d-flex align-items-center" + class="btn btn-blank align-items-center" data-container="body" data-boundary="viewport" data-placement="bottom" - @click.stop="stageChange(path)" + @click.stop.prevent="stageChange(path)" > <icon - :size="12" + :size="16" name="mobile-issue-close" + class="ml-auto mr-auto" /> </button> <button v-tooltip - :title="__('More actions')" + :aria-label="__('Discard changes')" + :title="__('Discard changes')" type="button" - class="btn btn-blank d-flex align-items-center" + class="btn btn-blank align-items-center" data-container="body" data-boundary="viewport" data-placement="bottom" - data-toggle="dropdown" - data-display="static" + @click.stop.prevent="showDiscardModal" > <icon - :size="12" - name="ellipsis_h" + :size="16" + name="remove" + class="ml-auto mr-auto" /> </button> - <div class="dropdown-menu dropdown-menu-right"> - <ul> - <li> - <button - type="button" - @click.stop="discardFileChanges(path)" - > - {{ __('Discard changes') }} - </button> - </li> - </ul> - </div> + <gl-modal + :id="modalId" + :header-title-text="modalTitle" + :footer-primary-button-text="__('Discard changes')" + footer-primary-button-variant="danger" + @submit="discardFileChanges(path)" + > + {{ __("You will loose all changes you've made to this file. This action cannot be undone.") }} + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue index 9cec73ec00e..86c40602074 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue @@ -25,22 +25,23 @@ export default { <template> <div v-once - class="multi-file-discard-btn" + class="multi-file-discard-btn d-flex" > <button v-tooltip :aria-label="__('Unstage changes')" :title="__('Unstage changes')" type="button" - class="btn btn-blank d-flex align-items-center" + class="btn btn-blank align-items-center" data-container="body" data-boundary="viewport" data-placement="bottom" - @click="unstageChange(path)" + @click.stop.prevent="unstageChange(path)" > <icon - :size="12" - name="history" + :size="16" + name="redo" + class="ml-auto mr-auto" /> </button> </div> diff --git a/app/assets/javascripts/ide/components/error_message.vue b/app/assets/javascripts/ide/components/error_message.vue index acbc98b7a7b..a20dc0a7006 100644 --- a/app/assets/javascripts/ide/components/error_message.vue +++ b/app/assets/javascripts/ide/components/error_message.vue @@ -1,11 +1,7 @@ <script> import { mapActions } from 'vuex'; -import LoadingIcon from '../../vue_shared/components/loading_icon.vue'; export default { - components: { - LoadingIcon, - }, props: { message: { type: Object, @@ -59,7 +55,7 @@ export default { @click.stop.prevent="clickAction" > {{ message.actionText }} - <loading-icon + <gl-loading-icon v-show="isLoading" inline /> diff --git a/app/assets/javascripts/ide/components/file_templates/bar.vue b/app/assets/javascripts/ide/components/file_templates/bar.vue new file mode 100644 index 00000000000..23be5f45f16 --- /dev/null +++ b/app/assets/javascripts/ide/components/file_templates/bar.vue @@ -0,0 +1,80 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import Dropdown from './dropdown.vue'; + +export default { + components: { + Dropdown, + }, + computed: { + ...mapGetters(['activeFile']), + ...mapGetters('fileTemplates', ['templateTypes']), + ...mapState('fileTemplates', ['selectedTemplateType', 'updateSuccess']), + showTemplatesDropdown() { + return Object.keys(this.selectedTemplateType).length > 0; + }, + }, + watch: { + activeFile: 'setInitialType', + }, + mounted() { + this.setInitialType(); + }, + methods: { + ...mapActions('fileTemplates', [ + 'setSelectedTemplateType', + 'fetchTemplate', + 'undoFileTemplate', + ]), + setInitialType() { + const initialTemplateType = this.templateTypes.find(t => t.name === this.activeFile.name); + + if (initialTemplateType) { + this.setSelectedTemplateType(initialTemplateType); + } + }, + selectTemplateType(templateType) { + this.setSelectedTemplateType(templateType); + }, + selectTemplate(template) { + this.fetchTemplate(template); + }, + undo() { + this.undoFileTemplate(); + }, + }, +}; +</script> + +<template> + <div class="d-flex align-items-center ide-file-templates"> + <strong class="append-right-default"> + {{ __('File templates') }} + </strong> + <dropdown + :data="templateTypes" + :label="selectedTemplateType.name || __('Choose a type...')" + class="mr-2" + @click="selectTemplateType" + /> + <dropdown + v-if="showTemplatesDropdown" + :label="__('Choose a template...')" + :is-async-data="true" + :searchable="true" + :title="__('File templates')" + class="mr-2" + @click="selectTemplate" + /> + <transition name="fade"> + <button + v-show="updateSuccess" + type="button" + class="btn btn-default" + @click="undo" + > + {{ __('Undo') }} + </button> + </transition> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/file_templates/dropdown.vue b/app/assets/javascripts/ide/components/file_templates/dropdown.vue new file mode 100644 index 00000000000..ef1f6de3a86 --- /dev/null +++ b/app/assets/javascripts/ide/components/file_templates/dropdown.vue @@ -0,0 +1,123 @@ +<script> +import $ from 'jquery'; +import { mapActions, mapState } from 'vuex'; +import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; + +export default { + components: { + DropdownButton, + }, + props: { + data: { + type: Array, + required: false, + default: () => [], + }, + label: { + type: String, + required: true, + }, + title: { + type: String, + required: false, + default: null, + }, + isAsyncData: { + type: Boolean, + required: false, + default: false, + }, + searchable: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + search: '', + }; + }, + computed: { + ...mapState('fileTemplates', ['templates', 'isLoading']), + outputData() { + return (this.isAsyncData ? this.templates : this.data).filter(t => { + if (!this.searchable) return true; + + return t.name.toLowerCase().indexOf(this.search.toLowerCase()) >= 0; + }); + }, + showLoading() { + return this.isAsyncData ? this.isLoading : false; + }, + }, + mounted() { + $(this.$el).on('show.bs.dropdown', this.fetchTemplatesIfAsync); + }, + beforeDestroy() { + $(this.$el).off('show.bs.dropdown', this.fetchTemplatesIfAsync); + }, + methods: { + ...mapActions('fileTemplates', ['fetchTemplateTypes']), + fetchTemplatesIfAsync() { + if (this.isAsyncData) { + this.fetchTemplateTypes(); + } + }, + clickItem(item) { + this.$emit('click', item); + }, + }, +}; +</script> + +<template> + <div class="dropdown"> + <dropdown-button + :toggle-text="label" + data-display="static" + /> + <div class="dropdown-menu pb-0"> + <div + v-if="title" + class="dropdown-title ml-0 mr-0" + > + {{ title }} + </div> + <div + v-if="!showLoading && searchable" + class="dropdown-input" + > + <input + v-model="search" + :placeholder="__('Filter...')" + type="search" + class="dropdown-input-field" + /> + <i + aria-hidden="true" + class="fa fa-search dropdown-input-search" + ></i> + </div> + <div class="dropdown-content"> + <gl-loading-icon + v-if="showLoading" + :size="2" + /> + <ul v-else> + <li + v-for="(item, index) in outputData" + :key="index" + > + <button + type="button" + @click="clickItem(item)" + > + {{ item.name }} + </button> + </li> + </ul> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 6a5ab35a16a..a3add3b778f 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -10,6 +10,7 @@ import RepoEditor from './repo_editor.vue'; import FindFile from './file_finder/index.vue'; import RightPane from './panes/right.vue'; import ErrorMessage from './error_message.vue'; +import CommitEditorHeader from './commit_sidebar/editor_header.vue'; const originalStopCallback = Mousetrap.stopCallback; @@ -23,6 +24,7 @@ export default { FindFile, RightPane, ErrorMessage, + CommitEditorHeader, }, computed: { ...mapState([ @@ -34,7 +36,7 @@ export default { 'currentProjectId', 'errorMessage', ]), - ...mapGetters(['activeFile', 'hasChanges', 'someUncommitedChanges']), + ...mapGetters(['activeFile', 'hasChanges', 'someUncommitedChanges', 'isCommitModeActive']), }, mounted() { window.onbeforeunload = e => this.onBeforeUnload(e); @@ -96,7 +98,12 @@ export default { <template v-if="activeFile" > + <commit-editor-header + v-if="isCommitModeActive" + :active-file="activeFile" + /> <repo-tabs + v-else :active-file="activeFile" :files="openFiles" :viewer="viewer" diff --git a/app/assets/javascripts/ide/components/jobs/list.vue b/app/assets/javascripts/ide/components/jobs/list.vue index 3b16b860ecd..acd37605d16 100644 --- a/app/assets/javascripts/ide/components/jobs/list.vue +++ b/app/assets/javascripts/ide/components/jobs/list.vue @@ -1,11 +1,9 @@ <script> import { mapActions } from 'vuex'; -import LoadingIcon from '../../../vue_shared/components/loading_icon.vue'; import Stage from './stage.vue'; export default { components: { - LoadingIcon, Stage, }, props: { @@ -26,10 +24,10 @@ export default { <template> <div> - <loading-icon + <gl-loading-icon v-if="loading && !stages.length" + :size="2" class="prepend-top-default" - size="2" /> <template v-else> <stage diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue index 15e881b7bc8..1c474acb4b2 100644 --- a/app/assets/javascripts/ide/components/jobs/stage.vue +++ b/app/assets/javascripts/ide/components/jobs/stage.vue @@ -2,7 +2,6 @@ import tooltip from '../../../vue_shared/directives/tooltip'; import Icon from '../../../vue_shared/components/icon.vue'; import CiIcon from '../../../vue_shared/components/ci_icon.vue'; -import LoadingIcon from '../../../vue_shared/components/loading_icon.vue'; import Item from './item.vue'; export default { @@ -12,7 +11,6 @@ export default { components: { Icon, CiIcon, - LoadingIcon, Item, }, props: { @@ -96,7 +94,7 @@ export default { v-show="!stage.isCollapsed" class="card-body" > - <loading-icon + <gl-loading-icon v-if="showLoadingIcon" /> <template v-else> diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue index fc612956688..c8343e77860 100644 --- a/app/assets/javascripts/ide/components/merge_requests/list.vue +++ b/app/assets/javascripts/ide/components/merge_requests/list.vue @@ -3,7 +3,6 @@ import { mapActions, mapState } from 'vuex'; import _ from 'underscore'; import { __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; -import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; import Item from './item.vue'; import TokenedInput from '../shared/tokened_input.vue'; @@ -14,7 +13,6 @@ const SEARCH_TYPES = [ export default { components: { - LoadingIcon, TokenedInput, Item, Icon, @@ -98,10 +96,10 @@ export default { </div> </div> <div class="dropdown-content ide-merge-requests-dropdown-content d-flex"> - <loading-icon + <gl-loading-icon v-if="isLoading" + :size="2" class="mt-3 mb-3 align-self-center ml-auto mr-auto" - size="2" /> <template v-else> <ul diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index e500ef0e1b5..bcd53ac1ba2 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -1,6 +1,7 @@ <script> +import $ from 'jquery'; import { __ } from '~/locale'; -import { mapActions, mapState } from 'vuex'; +import { mapActions, mapState, mapGetters } from 'vuex'; import GlModal from '~/vue_shared/components/gl_modal.vue'; import { modalTypes } from '../../constants'; @@ -15,6 +16,7 @@ export default { }, computed: { ...mapState(['entryModal']), + ...mapGetters('fileTemplates', ['templateTypes']), entryName: { get() { if (this.entryModal.type === modalTypes.rename) { @@ -31,7 +33,9 @@ export default { if (this.entryModal.type === modalTypes.tree) { return __('Create new directory'); } else if (this.entryModal.type === modalTypes.rename) { - return this.entryModal.entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file'); + return this.entryModal.entry.type === modalTypes.tree + ? __('Rename folder') + : __('Rename file'); } return __('Create new file'); @@ -40,11 +44,16 @@ export default { if (this.entryModal.type === modalTypes.tree) { return __('Create directory'); } else if (this.entryModal.type === modalTypes.rename) { - return this.entryModal.entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file'); + return this.entryModal.entry.type === modalTypes.tree + ? __('Rename folder') + : __('Rename file'); } return __('Create file'); }, + isCreatingNew() { + return this.entryModal.type !== modalTypes.rename; + }, }, methods: { ...mapActions(['createTempEntry', 'renameEntry']), @@ -61,6 +70,14 @@ export default { }); } }, + createFromTemplate(template) { + this.createTempEntry({ + name: template.name, + type: this.entryModal.type, + }); + + $('#ide-new-entry').modal('toggle'); + }, focusInput() { this.$refs.fieldName.focus(); }, @@ -77,6 +94,7 @@ export default { :header-title-text="modalTitle" :footer-primary-button-text="buttonLabel" footer-primary-button-variant="success" + modal-size="lg" @submit="submitForm" @open="focusInput" @closed="closedModal" @@ -84,16 +102,35 @@ export default { <div class="form-group row" > - <label class="label-bold col-form-label col-sm-3"> + <label class="label-bold col-form-label col-sm-2"> {{ __('Name') }} </label> - <div class="col-sm-9"> + <div class="col-sm-10"> <input ref="fieldName" v-model="entryName" type="text" class="form-control" + placeholder="/dir/file_name" /> + <ul + v-if="isCreatingNew" + class="prepend-top-default list-inline" + > + <li + v-for="(template, index) in templateTypes" + :key="index" + class="list-inline-item" + > + <button + type="button" + class="btn btn-missing p-1 pr-2 pl-2" + @click="createFromTemplate(template)" + > + {{ template.name }} + </button> + </li> + </ul> </div> </div> </gl-modal> diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index 5757dfdc925..0a2681b7a1e 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -2,7 +2,6 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import _ from 'underscore'; import { sprintf, __ } from '../../../locale'; -import LoadingIcon from '../../../vue_shared/components/loading_icon.vue'; import Icon from '../../../vue_shared/components/icon.vue'; import CiIcon from '../../../vue_shared/components/ci_icon.vue'; import Tabs from '../../../vue_shared/components/tabs/tabs'; @@ -12,7 +11,6 @@ import JobsList from '../jobs/list.vue'; export default { components: { - LoadingIcon, Icon, CiIcon, Tabs, @@ -50,10 +48,10 @@ export default { <template> <div class="ide-pipeline"> - <loading-icon + <gl-loading-icon v-if="showLoadingIcon" + :size="2" class="prepend-top-default" - size="2" /> <template v-else-if="latestPipeline !== null"> <header diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue index 39a1bd1f61b..37a8ad36507 100644 --- a/app/assets/javascripts/ide/components/preview/clientside.vue +++ b/app/assets/javascripts/ide/components/preview/clientside.vue @@ -3,14 +3,12 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import _ from 'underscore'; import { Manager } from 'smooshpack'; import { listen } from 'codesandbox-api'; -import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; import Navigator from './navigator.vue'; import { packageJsonPath } from '../../constants'; import { createPathWithExt } from '../../utils'; export default { components: { - LoadingIcon, Navigator, }, data() { @@ -177,9 +175,9 @@ export default { {{ s__('IDE|Get started with Live Preview') }} </a> </div> - <loading-icon + <gl-loading-icon v-else - size="2" + :size="2" class="align-self-center mt-auto mb-auto" /> </div> diff --git a/app/assets/javascripts/ide/components/preview/navigator.vue b/app/assets/javascripts/ide/components/preview/navigator.vue index 4bf346946b6..42f23801692 100644 --- a/app/assets/javascripts/ide/components/preview/navigator.vue +++ b/app/assets/javascripts/ide/components/preview/navigator.vue @@ -1,12 +1,10 @@ <script> import { listen } from 'codesandbox-api'; import Icon from '~/vue_shared/components/icon.vue'; -import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; export default { components: { Icon, - LoadingIcon, }, props: { manager: { @@ -138,7 +136,7 @@ export default { class="ide-navigator-location form-control bg-white" readonly /> - <loading-icon + <gl-loading-icon v-if="loading" class="position-absolute ide-preview-loading-icon" /> diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index 6f1a941fbc4..d3b24c5b793 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -95,8 +95,9 @@ export default { :file-list="changedFiles" :action-btn-text="__('Stage all changes')" :active-file-key="activeFileKey" + :empty-state-text="__('There are no unstaged changes')" action="stageAllChanges" - action-btn-icon="mobile-issue-close" + action-btn-icon="stage-all" item-action-component="stage-button" class="is-first" icon-name="unstaged" @@ -108,8 +109,9 @@ export default { :action-btn-text="__('Unstage all changes')" :staged-list="true" :active-file-key="activeFileKey" + :empty-state-text="__('There are no staged changes')" action="unstageAllChanges" - action-btn-icon="history" + action-btn-icon="unstage-all" item-action-component="unstage-button" icon-name="staged" /> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index f55aa843444..d3a73e84cc7 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -6,12 +6,14 @@ import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import { activityBarViews, viewerTypes } from '../constants'; import Editor from '../lib/editor'; import ExternalLink from './external_link.vue'; +import FileTemplatesBar from './file_templates/bar.vue'; export default { components: { ContentViewer, DiffViewer, ExternalLink, + FileTemplatesBar, }, props: { file: { @@ -34,6 +36,7 @@ export default { 'isCommitModeActive', 'isReviewModeActive', ]), + ...mapGetters('fileTemplates', ['showFileTemplatesBar']), shouldHideEditor() { return this.file && this.file.binary && !this.file.content; }, @@ -216,7 +219,7 @@ export default { id="ide" class="blob-viewer-container blob-editor-container" > - <div class="ide-mode-tabs clearfix" > + <div class="ide-mode-tabs clearfix"> <ul v-if="!shouldHideEditor && isEditModeActive" class="nav-links float-left" @@ -249,6 +252,9 @@ export default { :file="file" /> </div> + <file-templates-bar + v-if="showFileTemplatesBar(file.name)" + /> <div v-show="!shouldHideEditor && file.viewMode ==='editor'" ref="editor" diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index aa02dfbddc4..b8b64aead30 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -4,6 +4,7 @@ import { visitUrl } from '~/lib/utils/url_utility'; import flash from '~/flash'; import * as types from './mutation_types'; import FilesDecoratorWorker from './workers/files_decorator_worker'; +import { stageKeys } from '../constants'; export const redirectToUrl = (_, url) => visitUrl(url); @@ -122,14 +123,28 @@ export const scrollToTab = () => { }); }; -export const stageAllChanges = ({ state, commit }) => { +export const stageAllChanges = ({ state, commit, dispatch }) => { + const openFile = state.openFiles[0]; + commit(types.SET_LAST_COMMIT_MSG, ''); state.changedFiles.forEach(file => commit(types.STAGE_CHANGE, file.path)); + + dispatch('openPendingTab', { + file: state.stagedFiles.find(f => f.path === openFile.path), + keyPrefix: stageKeys.staged, + }); }; -export const unstageAllChanges = ({ state, commit }) => { +export const unstageAllChanges = ({ state, commit, dispatch }) => { + const openFile = state.openFiles[0]; + state.stagedFiles.forEach(file => commit(types.UNSTAGE_CHANGE, file.path)); + + dispatch('openPendingTab', { + file: state.changedFiles.find(f => f.path === openFile.path), + keyPrefix: stageKeys.unstaged, + }); }; export const updateViewer = ({ commit }, viewer) => { @@ -206,6 +221,7 @@ export const resetOpenFiles = ({ commit }) => commit(types.RESET_OPEN_FILES); export const renameEntry = ({ dispatch, commit, state }, { path, name, entryPath = null }) => { const entry = state.entries[entryPath || path]; + commit(types.RENAME_ENTRY, { path, name, entryPath }); if (entry.type === 'tree') { @@ -214,7 +230,7 @@ export const renameEntry = ({ dispatch, commit, state }, { path, name, entryPath ); } - if (!entryPath) { + if (!entryPath && !entry.tempFile) { dispatch('deleteEntry', path); } }; diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 28b9d0df201..30dcf7ef4df 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -5,7 +5,7 @@ import service from '../../services'; import * as types from '../mutation_types'; import router from '../../ide_router'; import { setPageTitle } from '../utils'; -import { viewerTypes } from '../../constants'; +import { viewerTypes, stageKeys } from '../../constants'; export const closeFile = ({ commit, state, dispatch }, file) => { const { path } = file; @@ -208,8 +208,9 @@ export const discardFileChanges = ({ dispatch, state, commit, getters }, path) = eventHub.$emit(`editor.update.model.dispose.unstaged-${file.key}`, file.content); }; -export const stageChange = ({ commit, state }, path) => { +export const stageChange = ({ commit, state, dispatch }, path) => { const stagedFile = state.stagedFiles.find(f => f.path === path); + const openFile = state.openFiles.find(f => f.path === path); commit(types.STAGE_CHANGE, path); commit(types.SET_LAST_COMMIT_MSG, ''); @@ -217,21 +218,39 @@ export const stageChange = ({ commit, state }, path) => { if (stagedFile) { eventHub.$emit(`editor.update.model.new.content.staged-${stagedFile.key}`, stagedFile.content); } + + if (openFile && openFile.active) { + const file = state.stagedFiles.find(f => f.path === path); + + dispatch('openPendingTab', { + file, + keyPrefix: stageKeys.staged, + }); + } }; -export const unstageChange = ({ commit }, path) => { +export const unstageChange = ({ commit, dispatch, state }, path) => { + const openFile = state.openFiles.find(f => f.path === path); + commit(types.UNSTAGE_CHANGE, path); + + if (openFile && openFile.active) { + const file = state.changedFiles.find(f => f.path === path); + + dispatch('openPendingTab', { + file, + keyPrefix: stageKeys.unstaged, + }); + } }; -export const openPendingTab = ({ commit, getters, dispatch, state }, { file, keyPrefix }) => { +export const openPendingTab = ({ commit, getters, state }, { file, keyPrefix }) => { if (getters.activeFile && getters.activeFile.key === `${keyPrefix}-${file.key}`) return false; state.openFiles.forEach(f => eventHub.$emit(`editor.update.model.dispose.${f.key}`)); commit(types.ADD_PENDING_TAB, { file, keyPrefix }); - dispatch('scrollToTab'); - router.push(`/project/${file.projectId}/tree/${state.currentBranchId}/`); return true; diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js index a601dc8f5a0..877d88bb060 100644 --- a/app/assets/javascripts/ide/stores/index.js +++ b/app/assets/javascripts/ide/stores/index.js @@ -8,6 +8,7 @@ import commitModule from './modules/commit'; import pipelines from './modules/pipelines'; import mergeRequests from './modules/merge_requests'; import branches from './modules/branches'; +import fileTemplates from './modules/file_templates'; Vue.use(Vuex); @@ -22,6 +23,7 @@ export const createStore = () => pipelines, mergeRequests, branches, + fileTemplates: fileTemplates(), }, }); diff --git a/app/assets/javascripts/ide/stores/modules/branches/mutations.js b/app/assets/javascripts/ide/stores/modules/branches/mutations.js index 081ec2d4c28..0a455f4500f 100644 --- a/app/assets/javascripts/ide/stores/modules/branches/mutations.js +++ b/app/assets/javascripts/ide/stores/modules/branches/mutations.js @@ -1,4 +1,3 @@ -/* eslint-disable no-param-reassign */ import * as types from './mutation_types'; export default { diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/actions.js b/app/assets/javascripts/ide/stores/modules/file_templates/actions.js index 43237a29466..dd53213ed18 100644 --- a/app/assets/javascripts/ide/stores/modules/file_templates/actions.js +++ b/app/assets/javascripts/ide/stores/modules/file_templates/actions.js @@ -1,6 +1,7 @@ import Api from '~/api'; import { __ } from '~/locale'; import * as types from './mutation_types'; +import eventHub from '../../../eventhub'; export const requestTemplateTypes = ({ commit }) => commit(types.REQUEST_TEMPLATE_TYPES); export const receiveTemplateTypesError = ({ commit, dispatch }) => { @@ -31,9 +32,23 @@ export const fetchTemplateTypes = ({ dispatch, state }) => { .catch(() => dispatch('receiveTemplateTypesError')); }; -export const setSelectedTemplateType = ({ commit }, type) => +export const setSelectedTemplateType = ({ commit, dispatch, rootGetters }, type) => { commit(types.SET_SELECTED_TEMPLATE_TYPE, type); + if (rootGetters.activeFile.prevPath === type.name) { + dispatch('discardFileChanges', rootGetters.activeFile.path, { root: true }); + } else if (rootGetters.activeFile.name !== type.name) { + dispatch( + 'renameEntry', + { + path: rootGetters.activeFile.path, + name: type.name, + }, + { root: true }, + ); + } +}; + export const receiveTemplateError = ({ dispatch }, template) => { dispatch( 'setErrorMessage', @@ -69,6 +84,7 @@ export const setFileTemplate = ({ dispatch, commit, rootGetters }, template) => { root: true }, ); commit(types.SET_UPDATE_SUCCESS, true); + eventHub.$emit(`editor.update.model.new.content.${rootGetters.activeFile.key}`, template.content); }; export const undoFileTemplate = ({ dispatch, commit, rootGetters }) => { @@ -76,6 +92,12 @@ export const undoFileTemplate = ({ dispatch, commit, rootGetters }) => { dispatch('changeFileContent', { path: file.path, content: file.raw }, { root: true }); commit(types.SET_UPDATE_SUCCESS, false); + + eventHub.$emit(`editor.update.model.new.content.${file.key}`, file.raw); + + if (file.prevPath) { + dispatch('discardFileChanges', file.path, { root: true }); + } }; // prevent babel-plugin-rewire from generating an invalid default during karma tests diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js index 38318fd49bf..628babe6a01 100644 --- a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js +++ b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js @@ -1,3 +1,5 @@ +import { activityBarViews } from '../../../constants'; + export const templateTypes = () => [ { name: '.gitlab-ci.yml', @@ -17,7 +19,8 @@ export const templateTypes = () => [ }, ]; -export const showFileTemplatesBar = (_, getters) => name => - getters.templateTypes.find(t => t.name === name); +export const showFileTemplatesBar = (_, getters, rootState) => name => + getters.templateTypes.find(t => t.name === name) && + rootState.currentActivityView === activityBarViews.edit; export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/index.js b/app/assets/javascripts/ide/stores/modules/file_templates/index.js index dfa5ef54413..383ff5db392 100644 --- a/app/assets/javascripts/ide/stores/modules/file_templates/index.js +++ b/app/assets/javascripts/ide/stores/modules/file_templates/index.js @@ -3,10 +3,10 @@ import * as actions from './actions'; import * as getters from './getters'; import mutations from './mutations'; -export default { +export default () => ({ namespaced: true, actions, state: createState(), getters, mutations, -}; +}); diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/mutations.js b/app/assets/javascripts/ide/stores/modules/file_templates/mutations.js index e413e61eaaa..674782a28ca 100644 --- a/app/assets/javascripts/ide/stores/modules/file_templates/mutations.js +++ b/app/assets/javascripts/ide/stores/modules/file_templates/mutations.js @@ -1,4 +1,3 @@ -/* eslint-disable no-param-reassign */ import * as types from './mutation_types'; export default { diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js b/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js index 98102a68e08..0eba9c39817 100644 --- a/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js +++ b/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js @@ -1,4 +1,3 @@ -/* eslint-disable no-param-reassign */ import * as types from './mutation_types'; export default { diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js b/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js index 5a2213bbe89..b4be100cb07 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js @@ -1,4 +1,3 @@ -/* eslint-disable no-param-reassign */ import * as types from './mutation_types'; import { normalizeJob } from './utils'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index 0347f803757..2c8535bda59 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -1,4 +1,4 @@ -/* eslint-disable no-param-reassign */ +import Vue from 'vue'; import * as types from './mutation_types'; import projectMutations from './mutations/project'; import mergeRequestMutation from './mutations/merge_request'; @@ -227,7 +227,7 @@ export default { path: newPath, name: entryPath ? oldEntry.name : name, tempFile: true, - prevPath: oldEntry.path, + prevPath: oldEntry.tempFile ? null : oldEntry.path, url: oldEntry.url.replace(new RegExp(`${oldEntry.path}/?$`), newPath), tree: [], parentPath, @@ -246,6 +246,20 @@ export default { if (newEntry.type === 'blob') { state.changedFiles = state.changedFiles.concat(newEntry); } + + if (state.entries[newPath].opened) { + state.openFiles.push(state.entries[newPath]); + } + + if (oldEntry.tempFile) { + const filterMethod = f => f.path !== oldEntry.path; + + state.openFiles = state.openFiles.filter(filterMethod); + state.changedFiles = state.changedFiles.filter(filterMethod); + parent.tree = parent.tree.filter(filterMethod); + + Vue.delete(state.entries, oldEntry.path); + } }, ...projectMutations, ...mergeRequestMutation, diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index a937fb157f8..6ca246c1d63 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -1,4 +1,3 @@ -/* eslint-disable no-param-reassign */ import * as types from '../mutation_types'; import { sortTree } from '../utils'; import { diffModes } from '../../constants'; @@ -56,7 +55,7 @@ export default { f => f.path === file.path && f.pending && !(f.tempFile && !f.prevPath), ); - if (file.tempFile) { + if (file.tempFile && file.content === '') { Object.assign(state.entries[file.path], { content: raw, }); diff --git a/app/assets/javascripts/jobs/components/header.vue b/app/assets/javascripts/jobs/components/header.vue index 1e7f4b2c3f7..3e49b04e44e 100644 --- a/app/assets/javascripts/jobs/components/header.vue +++ b/app/assets/javascripts/jobs/components/header.vue @@ -1,13 +1,11 @@ <script> import ciHeader from '../../vue_shared/components/header_ci_component.vue'; -import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import callout from '../../vue_shared/components/callout.vue'; export default { name: 'JobHeaderSection', components: { ciHeader, - loadingIcon, callout, }, props: { @@ -82,9 +80,9 @@ export default { :should-render-triggered-label="jobStarted" item-name="Job" /> - <loading-icon + <gl-loading-icon v-if="isLoading" - size="2" + :size="2" class="prepend-top-default append-bottom-default" /> </div> diff --git a/app/assets/javascripts/jobs/components/sidebar_details_block.vue b/app/assets/javascripts/jobs/components/sidebar_details_block.vue index 36d4a3e2bc9..1210ccd038a 100644 --- a/app/assets/javascripts/jobs/components/sidebar_details_block.vue +++ b/app/assets/javascripts/jobs/components/sidebar_details_block.vue @@ -1,5 +1,4 @@ <script> -import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; import Icon from '~/vue_shared/components/icon.vue'; @@ -9,7 +8,6 @@ export default { name: 'SidebarDetailsBlock', components: { DetailRow, - LoadingIcon, Icon, }, mixins: [timeagoMixin], @@ -232,10 +230,10 @@ export default { </div> </div> </template> - <loading-icon + <gl-loading-icon v-if="isLoading" + :size="2" class="prepend-top-10" - size="2" /> </div> </template> diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js index 2a451ef0cd1..cd12ef87d40 100644 --- a/app/assets/javascripts/jobs/store/mutations.js +++ b/app/assets/javascripts/jobs/store/mutations.js @@ -1,5 +1,3 @@ -/* eslint-disable no-param-reassign */ - import * as types from './mutation_types'; export default { diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 3e208764b3e..0849d97bc1a 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -131,16 +131,43 @@ export const parseUrlPathname = url => { return parsedUrl.pathname.charAt(0) === '/' ? parsedUrl.pathname : `/${parsedUrl.pathname}`; }; -// We can trust that each param has one & since values containing & will be encoded -// Remove the first character of search as it is always ? -export const getUrlParamsArray = () => - window.location.search - .slice(1) - .split('&') - .map(param => { - const split = param.split('='); - return [decodeURI(split[0]), split[1]].join('='); - }); +const splitPath = (path = '') => path + .replace(/^\?/, '') + .split('&'); + +export const urlParamsToArray = (path = '') => splitPath(path) + .filter(param => param.length > 0) + .map(param => { + const split = param.split('='); + return [decodeURI(split[0]), split[1]].join('='); + }); + +export const getUrlParamsArray = () => urlParamsToArray(window.location.search); + +export const urlParamsToObject = (path = '') => splitPath(path) + .reduce((dataParam, filterParam) => { + if (filterParam === '') { + return dataParam; + } + + const data = dataParam; + let [key, value] = filterParam.split('='); + const isArray = key.includes('[]'); + key = key.replace('[]', ''); + value = decodeURIComponent(value.replace(/\+/g, ' ')); + + if (isArray) { + if (!data[key]) { + data[key] = []; + } + + data[key].push(value); + } else { + data[key] = value; + } + + return data; + }, {}); export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 2be3c97bd95..879f94a26ec 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -49,6 +49,16 @@ export const dasherize = str => str.replace(/[_\s]+/g, '-'); export const slugify = str => str.trim().toLowerCase(); /** + * Replaces whitespaces with hyphens and converts to lower case + * @param {String} str + * @returns {String} + */ +export const slugifyWithHyphens = str => { + const regex = new RegExp(/\s+/, 'g'); + return str.toLowerCase().replace(regex, '-'); +}; + +/** * Truncates given text * * @param {String} string diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 72b72f4247d..a282c2df441 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -47,9 +47,9 @@ export function removeParamQueryString(url, param) { return urlVariables.filter(variable => variable.indexOf(param) === -1).join('&'); } -export function removeParams(params) { +export function removeParams(params, source = window.location.href) { const url = document.createElement('a'); - url.href = window.location.href; + url.href = source; params.forEach(param => { url.search = removeParamQueryString(url.search, param); diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 2718f73a830..c5a5f64abac 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -29,6 +29,7 @@ import './milestone_select'; import './frequent_items'; import initBreadcrumbs from './breadcrumb'; import initDispatcher from './dispatcher'; +import initUsagePingConsent from './usage_ping_consent'; // expose jQuery as global (TODO: remove these) window.jQuery = jQuery; @@ -78,6 +79,7 @@ document.addEventListener('DOMContentLoaded', () => { initImporterStatus(); initTodoToggle(); initLogoAnimation(); + initUsagePingConsent(); // Set the default path for all cookies to GitLab's root directory Cookies.defaults.path = gon.relative_url_root || '/'; diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue index e5680a0499f..a13f30e6079 100644 --- a/app/assets/javascripts/monitoring/components/graph.vue +++ b/app/assets/javascripts/monitoring/components/graph.vue @@ -82,11 +82,12 @@ export default { value: 0, }, currentXCoordinate: 0, - currentCoordinates: [], + currentCoordinates: {}, showFlag: false, showFlagContent: false, timeSeries: [], realPixelRatio: 1, + seriesUnderMouse: [], }; }, computed: { @@ -126,6 +127,9 @@ export default { this.draw(); }, methods: { + showDot(path) { + return this.showFlagContent && this.seriesUnderMouse.includes(path); + }, draw() { const breakpointSize = bp.getBreakpointSize(); const query = this.graphData.queries[0]; @@ -155,7 +159,24 @@ export default { point.y = e.clientY; point = point.matrixTransform(this.$refs.graphData.getScreenCTM().inverse()); point.x += 7; - const firstTimeSeries = this.timeSeries[0]; + + this.seriesUnderMouse = this.timeSeries.filter((series) => { + const mouseX = series.timeSeriesScaleX.invert(point.x); + let minDistance = Infinity; + + const closestTickMark = Object.keys(this.allXAxisValues).reduce((closest, x) => { + const distance = Math.abs(Number(new Date(x)) - Number(mouseX)); + if (distance < minDistance) { + minDistance = distance; + return x; + } + return closest; + }); + + return series.values.find(v => v.time.toString() === closestTickMark); + }); + + const firstTimeSeries = this.seriesUnderMouse[0]; const timeValueOverlay = firstTimeSeries.timeSeriesScaleX.invert(point.x); const overlayIndex = bisectDate(firstTimeSeries.values, timeValueOverlay, 1); const d0 = firstTimeSeries.values[overlayIndex - 1]; @@ -190,6 +211,17 @@ export default { axisXScale.domain(d3.extent(allValues, d => d.time)); axisYScale.domain([0, d3.max(allValues.map(d => d.value))]); + this.allXAxisValues = this.timeSeries.reduce((obj, series) => { + const seriesKeys = {}; + series.values.forEach(v => { + seriesKeys[v.time] = true; + }); + return { + ...obj, + ...seriesKeys, + }; + }, {}); + const xAxis = d3 .axisBottom() .scale(axisXScale) @@ -277,9 +309,8 @@ export default { :line-style="path.lineStyle" :line-color="path.lineColor" :area-color="path.areaColor" - :current-coordinates="currentCoordinates[index]" - :current-time-series-index="index" - :show-dot="showFlagContent" + :current-coordinates="currentCoordinates[path.metricTag]" + :show-dot="showDot(path)" /> <graph-deployment :deployment-data="reducedDeploymentData" @@ -303,7 +334,7 @@ export default { :graph-height="graphHeight" :graph-height-offset="graphHeightOffset" :show-flag-content="showFlagContent" - :time-series="timeSeries" + :time-series="seriesUnderMouse" :unit-of-display="unitOfDisplay" :legend-title="legendTitle" :deployment-flag-data="deploymentFlagData" diff --git a/app/assets/javascripts/monitoring/components/graph/flag.vue b/app/assets/javascripts/monitoring/components/graph/flag.vue index 1e6803abf3a..5f00d20ca3f 100644 --- a/app/assets/javascripts/monitoring/components/graph/flag.vue +++ b/app/assets/javascripts/monitoring/components/graph/flag.vue @@ -52,7 +52,7 @@ export default { required: true, }, currentCoordinates: { - type: Array, + type: Object, required: true, }, }, @@ -91,8 +91,8 @@ export default { }, methods: { seriesMetricValue(seriesIndex, series) { - const indexFromCoordinates = this.currentCoordinates[seriesIndex] - ? this.currentCoordinates[seriesIndex].currentDataIndex : 0; + const indexFromCoordinates = this.currentCoordinates[series.metricTag] + ? this.currentCoordinates[series.metricTag].currentDataIndex : 0; const index = this.deploymentFlagData ? this.deploymentFlagData.seriesIndex : indexFromCoordinates; diff --git a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js index 4f23814ff3e..007451d5c7a 100644 --- a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js +++ b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js @@ -50,19 +50,24 @@ const mixins = { }, positionFlag() { - const timeSeries = this.timeSeries[0]; - const hoveredDataIndex = bisectDate(timeSeries.values, this.hoverData.hoveredDate, 1); + const timeSeries = this.seriesUnderMouse[0]; + if (!timeSeries) { + return; + } + const hoveredDataIndex = bisectDate(timeSeries.values, this.hoverData.hoveredDate); this.currentData = timeSeries.values[hoveredDataIndex]; this.currentXCoordinate = Math.floor(timeSeries.timeSeriesScaleX(this.currentData.time)); - this.currentCoordinates = this.timeSeries.map((series) => { - const currentDataIndex = bisectDate(series.values, this.hoverData.hoveredDate, 1); + this.currentCoordinates = {}; + + this.seriesUnderMouse.forEach((series) => { + const currentDataIndex = bisectDate(series.values, this.hoverData.hoveredDate); const currentData = series.values[currentDataIndex]; const currentX = Math.floor(series.timeSeriesScaleX(currentData.time)); const currentY = Math.floor(series.timeSeriesScaleY(currentData.value)); - return { + this.currentCoordinates[series.metricTag] = { currentX, currentY, currentDataIndex, diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js index cee39fd0559..eff0d7325cd 100644 --- a/app/assets/javascripts/monitoring/utils/multiple_time_series.js +++ b/app/assets/javascripts/monitoring/utils/multiple_time_series.js @@ -2,7 +2,7 @@ import _ from 'underscore'; import { scaleLinear, scaleTime } from 'd3-scale'; import { line, area, curveLinear } from 'd3-shape'; import { extent, max, sum } from 'd3-array'; -import { timeMinute } from 'd3-time'; +import { timeMinute, timeSecond } from 'd3-time'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; const d3 = { @@ -14,6 +14,7 @@ const d3 = { extent, max, timeMinute, + timeSecond, sum, }; @@ -51,6 +52,24 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom return defaultColorPalette[pick]; } + function findByDate(series, time) { + const val = series.find(v => Math.abs(d3.timeSecond.count(time, v.time)) < 60); + if (val) { + return val.value; + } + return NaN; + } + + // The timeseries data may have gaps in it + // but we need a regularly-spaced set of time/value pairs + // this gives us a complete range of one minute intervals + // offset the same amount as the original data + const [minX, maxX] = xDom; + const offset = d3.timeMinute(minX) - Number(minX); + const datesWithoutGaps = d3.timeSecond.every(60) + .range(d3.timeMinute.offset(minX, -1), maxX) + .map(d => d - offset); + query.result.forEach((timeSeries, timeSeriesNumber) => { let metricTag = ''; let lineColor = ''; @@ -119,9 +138,14 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom }); } + const values = datesWithoutGaps.map(time => ({ + time, + value: findByDate(timeSeries.values, time), + })); + timeSeriesParsed.push({ - linePath: lineFunction(timeSeries.values), - areaPath: areaFunction(timeSeries.values), + linePath: lineFunction(values), + areaPath: areaFunction(values), timeSeriesScaleX, timeSeriesScaleY, values: timeSeries.values, diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 1e168742667..8b1d8f6055e 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -154,7 +154,11 @@ export default class Notes { this.$wrapperEl.on('click', '.system-note-commit-list-toggler', this.toggleCommitList); this.$wrapperEl.on('click', '.js-toggle-lazy-diff', this.loadLazyDiff); - this.$wrapperEl.on('click', '.js-toggle-lazy-diff-retry-button', this.onClickRetryLazyLoad.bind(this)); + this.$wrapperEl.on( + 'click', + '.js-toggle-lazy-diff-retry-button', + this.onClickRetryLazyLoad.bind(this), + ); // fetch notes when tab becomes visible this.$wrapperEl.on('visibilitychange', this.visibilityChange); @@ -252,9 +256,7 @@ export default class Notes { discussionNoteForm = $textarea.closest('.js-discussion-note-form'); if (discussionNoteForm.length) { if ($textarea.val() !== '') { - if ( - !window.confirm('Are you sure you want to cancel creating this comment?') - ) { + if (!window.confirm('Are you sure you want to cancel creating this comment?')) { return; } } @@ -266,9 +268,7 @@ export default class Notes { originalText = $textarea.closest('form').data('originalNote'); newText = $textarea.val(); if (originalText !== newText) { - if ( - !window.confirm('Are you sure you want to cancel editing this comment?') - ) { + if (!window.confirm('Are you sure you want to cancel editing this comment?')) { return; } } @@ -1316,8 +1316,7 @@ export default class Notes { $retryButton.prop('disabled', true); - return this.loadLazyDiff(e) - .then(() => { + return this.loadLazyDiff(e).then(() => { $retryButton.prop('disabled', false); }); } @@ -1343,18 +1342,18 @@ export default class Notes { */ if (url) { return axios - .get(url) - .then(({ data }) => { - // Reset state in case last request returned error - $successContainer.removeClass('hidden'); - $errorContainer.addClass('hidden'); - - Notes.renderDiffContent($container, data); - }) - .catch(() => { - $successContainer.addClass('hidden'); - $errorContainer.removeClass('hidden'); - }); + .get(url) + .then(({ data }) => { + // Reset state in case last request returned error + $successContainer.removeClass('hidden'); + $errorContainer.addClass('hidden'); + + Notes.renderDiffContent($container, data); + }) + .catch(() => { + $successContainer.addClass('hidden'); + $errorContainer.removeClass('hidden'); + }); } return Promise.resolve(); } @@ -1545,12 +1544,8 @@ export default class Notes { <div class="note-header"> <div class="note-header-info"> <a href="/${_.escape(currentUsername)}"> - <span class="d-none d-sm-inline-block">${_.escape( - currentUsername, - )}</span> - <span class="note-headline-light">${_.escape( - currentUsername, - )}</span> + <span class="d-none d-sm-inline-block">${_.escape(currentUsername)}</span> + <span class="note-headline-light">${_.escape(currentUsername)}</span> </a> </div> </div> @@ -1565,9 +1560,7 @@ export default class Notes { ); $tempNote.find('.d-none.d-sm-inline-block').text(_.escape(currentUserFullname)); - $tempNote - .find('.note-headline-light') - .text(`@${_.escape(currentUsername)}`); + $tempNote.find('.note-headline-light').text(`@${_.escape(currentUsername)}`); return $tempNote; } diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index cdbbb342331..beb53da0e6d 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -7,7 +7,6 @@ import editSvg from 'icons/_icon_pencil.svg'; import resolveDiscussionSvg from 'icons/_icon_resolve_discussion.svg'; import resolvedDiscussionSvg from 'icons/_icon_status_success_solid.svg'; import ellipsisSvg from 'icons/_ellipsis_v.svg'; -import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; export default { @@ -15,21 +14,19 @@ export default { directives: { tooltip, }, - components: { - loadingIcon, - }, props: { authorId: { type: Number, required: true, }, noteId: { - type: Number, + type: String, required: true, }, noteUrl: { type: String, - required: true, + required: false, + default: '', }, accessLevel: { type: String, @@ -152,9 +149,9 @@ export default { v-else v-html="resolveDiscussionSvg"></div> </template> - <loading-icon + <gl-loading-icon v-else - :inline="true" + inline /> </button> </div> @@ -171,7 +168,7 @@ export default { href="#" title="Add reaction" > - <loading-icon :inline="true" /> + <gl-loading-icon inline/> <span class="link-highlight award-control-icon-neutral" v-html="emojiSmiling"> @@ -225,11 +222,11 @@ export default { Report as abuse </a> </li> - <li> + <li v-if="noteUrl"> <button :data-clipboard-text="noteUrl" type="button" - css-class="btn-default btn-transparent" + class="btn-default btn-transparent js-btn-copy-note-link" > Copy link </button> diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index e111d3b9ac2..051b17e9aa9 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -25,7 +25,7 @@ export default { required: true, }, noteId: { - type: Number, + type: String, required: true, }, canAwardEmoji: { diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index abcd4422d7c..c41ed070383 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -20,9 +20,9 @@ export default { default: '', }, noteId: { - type: Number, + type: String, required: false, - default: 0, + default: '', }, markdownVersion: { type: Number, @@ -67,7 +67,10 @@ export default { 'getUserDataByProp', ]), noteHash() { - return `#note_${this.noteId}`; + if (this.noteId) { + return `#note_${this.noteId}`; + } + return '#'; }, markdownPreviewPath() { return this.getNoteableDataByProp('preview_note_path'); diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index a621418cf72..d669d12a39b 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -9,7 +9,8 @@ export default { props: { author: { type: Object, - required: true, + required: false, + default: () => ({}), }, createdAt: { type: String, @@ -21,7 +22,7 @@ export default { default: '', }, noteId: { - type: Number, + type: String, required: true, }, includeToggle: { @@ -72,7 +73,10 @@ export default { {{ __('Toggle discussion') }} </button> </div> - <a :href="author.path"> + <a + v-if="Object.keys(author).length" + :href="author.path" + > <span class="note-header-author-name">{{ author.name }}</span> <span v-if="author.status_tooltip_html" @@ -81,6 +85,9 @@ export default { @{{ author.username }} </span> </a> + <span v-else> + {{ __('A deleted user') }} + </span> <span class="note-headline-light"> <span class="note-headline-meta"> <template v-if="actionText"> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 0fe1c16854a..afe86911230 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -137,8 +137,10 @@ export default { return this.unresolvedDiscussions.length > 1; }, showJumpToNextDiscussion() { - return this.hasMultipleUnresolvedDiscussions && - !this.isLastUnresolvedDiscussion(this.discussion.id, this.discussionsByDiffOrder); + return ( + this.hasMultipleUnresolvedDiscussions && + !this.isLastUnresolvedDiscussion(this.discussion.id, this.discussionsByDiffOrder) + ); }, shouldRenderDiffs() { const { diffDiscussion, diffFile } = this.transformedDiscussion; @@ -256,11 +258,16 @@ Please check your network connection and try again.`; }); }, jumpToNextDiscussion() { - const nextId = - this.nextUnresolvedDiscussionId(this.discussion.id, this.discussionsByDiffOrder); + const nextId = this.nextUnresolvedDiscussionId( + this.discussion.id, + this.discussionsByDiffOrder, + ); this.jumpToDiscussion(nextId); }, + deleteNoteHandler(note) { + this.$emit('noteDeleted', this.discussion, note); + }, }, }; </script> @@ -270,6 +277,7 @@ Please check your network connection and try again.`; <div class="timeline-entry-inner"> <div class="timeline-icon"> <user-avatar-link + v-if="author" :link-href="author.path" :img-src="author.avatar_url" :img-alt="author.name" @@ -344,6 +352,7 @@ Please check your network connection and try again.`; :is="componentName(note)" :note="componentData(note)" :key="note.id" + @handleDeleteNote="deleteNoteHandler" /> </ul> <div diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 4ebeb5599f2..7579fc852c6 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -86,6 +86,7 @@ export default { // eslint-disable-next-line no-alert if (window.confirm('Are you sure you want to delete this comment?')) { this.isDeleting = true; + this.$emit('handleDeleteNote', this.note); this.deleteNote(this.note) .then(() => { diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 9b8713b40fb..42c87fdf54a 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -10,7 +10,6 @@ import systemNote from '../../vue_shared/components/notes/system_note.vue'; import commentForm from './comment_form.vue'; import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; -import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue'; export default { @@ -20,7 +19,6 @@ export default { noteableDiscussion, systemNote, commentForm, - loadingIcon, placeholderNote, placeholderSystemNote, }, @@ -138,6 +136,7 @@ export default { .then(() => { this.isLoading = false; this.setNotesFetchedState(true); + eventHub.$emit('fetchedNotesData'); }) .then(() => this.$nextTick()) .then(() => this.checkLocationHash()) diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 3eefbe11c37..9a2ec15debd 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -43,14 +43,23 @@ export const fetchDiscussions = ({ commit }, path) => commit(types.SET_INITIAL_DISCUSSIONS, discussions); }); -export const refetchDiscussionById = ({ commit }, { path, discussionId }) => - service - .fetchDiscussions(path) - .then(res => res.json()) - .then(discussions => { - const selectedDiscussion = discussions.find(discussion => discussion.id === discussionId); - if (selectedDiscussion) commit(types.UPDATE_DISCUSSION, selectedDiscussion); - }); +export const refetchDiscussionById = ({ commit, state }, { path, discussionId }) => + new Promise(resolve => { + service + .fetchDiscussions(path) + .then(res => res.json()) + .then(discussions => { + const selectedDiscussion = discussions.find(discussion => discussion.id === discussionId); + if (selectedDiscussion) { + commit(types.UPDATE_DISCUSSION, selectedDiscussion); + // We need to refetch as it is now the transformed one in state + const discussion = utils.findNoteObjectById(state.discussions, discussionId); + + resolve(discussion); + } + }) + .catch(() => {}); + }); export const deleteNote = ({ commit }, note) => service.deleteNote(note.path).then(() => { @@ -152,26 +161,28 @@ export const saveNote = ({ commit, dispatch }, noteData) => { const replyId = noteData.data.in_reply_to_discussion_id; const methodToDispatch = replyId ? 'replyToDiscussion' : 'createNewNote'; - commit(types.REMOVE_PLACEHOLDER_NOTES); // remove previous placeholders $('.notes-form .flash-container').hide(); // hide previous flash notification + commit(types.REMOVE_PLACEHOLDER_NOTES); // remove previous placeholders - if (hasQuickActions) { - placeholderText = utils.stripQuickActions(placeholderText); - } + if (replyId) { + if (hasQuickActions) { + placeholderText = utils.stripQuickActions(placeholderText); + } - if (placeholderText.length) { - commit(types.SHOW_PLACEHOLDER_NOTE, { - noteBody: placeholderText, - replyId, - }); - } + if (placeholderText.length) { + commit(types.SHOW_PLACEHOLDER_NOTE, { + noteBody: placeholderText, + replyId, + }); + } - if (hasQuickActions) { - commit(types.SHOW_PLACEHOLDER_NOTE, { - isSystemNote: true, - noteBody: utils.getQuickActionText(note), - replyId, - }); + if (hasQuickActions) { + commit(types.SHOW_PLACEHOLDER_NOTE, { + isSystemNote: true, + noteBody: utils.getQuickActionText(note), + replyId, + }); + } } return dispatch(methodToDispatch, noteData).then(res => { @@ -211,7 +222,9 @@ export const saveNote = ({ commit, dispatch }, noteData) => { if (errors && errors.commands_only) { Flash(errors.commands_only, 'notice', noteData.flashContainer); } - commit(types.REMOVE_PLACEHOLDER_NOTES); + if (replyId) { + commit(types.REMOVE_PLACEHOLDER_NOTES); + } return res; }); diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 5b3b9f8776f..d4babf1fab2 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -1,5 +1,6 @@ import _ from 'underscore'; import * as constants from '../constants'; +import { reduceDiscussionsToLineCodes } from './utils'; import { collapseSystemNotes } from './collapse_utils'; export const discussions = state => collapseSystemNotes(state.discussions); @@ -28,17 +29,8 @@ export const notesById = state => return acc; }, {}); -export const discussionsByLineCode = state => - state.discussions.reduce((acc, note) => { - if (note.diff_discussion && note.line_code && note.resolvable) { - // For context about line notes: there might be multiple notes with the same line code - const items = acc[note.line_code] || []; - items.push(note); - - Object.assign(acc, { [note.line_code]: items }); - } - return acc; - }, {}); +export const discussionsStructuredByLineCode = state => + reduceDiscussionsToLineCodes(state.discussions); export const noteableType = state => { const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE, EPIC_NOTEABLE_TYPE } = constants; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index ab6a95e2601..2c04bfea122 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -54,13 +54,12 @@ export default { [types.EXPAND_DISCUSSION](state, { discussionId }) { const discussion = utils.findNoteObjectById(state.discussions, discussionId); - - discussion.expanded = true; + Object.assign(discussion, { expanded: true }); }, [types.COLLAPSE_DISCUSSION](state, { discussionId }) { const discussion = utils.findNoteObjectById(state.discussions, discussionId); - discussion.expanded = false; + Object.assign(discussion, { expanded: false }); }, [types.REMOVE_PLACEHOLDER_NOTES](state) { @@ -95,10 +94,15 @@ export default { [types.SET_USER_DATA](state, data) { Object.assign(state, { userData: data }); }, + [types.SET_INITIAL_DISCUSSIONS](state, discussionsData) { const discussions = []; discussionsData.forEach(discussion => { + if (discussion.diff_file) { + Object.assign(discussion, { fileHash: discussion.diff_file.file_hash }); + } + // To support legacy notes, should be very rare case. if (discussion.individual_note && discussion.notes.length > 1) { discussion.notes.forEach(n => { @@ -168,8 +172,7 @@ export default { [types.TOGGLE_DISCUSSION](state, { discussionId }) { const discussion = utils.findNoteObjectById(state.discussions, discussionId); - - discussion.expanded = !discussion.expanded; + Object.assign(discussion, { expanded: !discussion.expanded }); }, [types.UPDATE_NOTE](state, note) { @@ -185,16 +188,12 @@ export default { [types.UPDATE_DISCUSSION](state, noteData) { const note = noteData; - let index = 0; - - state.discussions.forEach((n, i) => { - if (n.id === note.id) { - index = i; - } - }); - + const selectedDiscussion = state.discussions.find(disc => disc.id === note.id); note.expanded = true; // override expand flag to prevent collapse - state.discussions.splice(index, 1, note); + if (note.diff_file) { + Object.assign(note, { fileHash: note.diff_file.file_hash }); + } + Object.assign(selectedDiscussion, { ...note }); }, [types.CLOSE_ISSUE](state) { diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js index a0e096ebfaf..8ccbdb4c130 100644 --- a/app/assets/javascripts/notes/stores/utils.js +++ b/app/assets/javascripts/notes/stores/utils.js @@ -2,13 +2,11 @@ import AjaxCache from '~/lib/utils/ajax_cache'; const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm; -export const findNoteObjectById = (notes, id) => - notes.filter(n => n.id === id)[0]; +export const findNoteObjectById = (notes, id) => notes.filter(n => n.id === id)[0]; export const getQuickActionText = note => { let text = 'Applying command'; - const quickActions = - AjaxCache.get(gl.GfmAutoComplete.dataSources.commands) || []; + const quickActions = AjaxCache.get(gl.GfmAutoComplete.dataSources.commands) || []; const executedCommands = quickActions.filter(command => { const commandRegex = new RegExp(`/${command.name}`); @@ -27,7 +25,18 @@ export const getQuickActionText = note => { return text; }; +export const reduceDiscussionsToLineCodes = selectedDiscussions => + selectedDiscussions.reduce((acc, note) => { + if (note.diff_discussion && note.line_code && note.resolvable) { + // For context about line notes: there might be multiple notes with the same line code + const items = acc[note.line_code] || []; + items.push(note); + + Object.assign(acc, { [note.line_code]: items }); + } + return acc; + }, {}); + export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note); -export const stripQuickActions = note => - note.replace(REGEX_QUICK_ACTIONS, '').trim(); +export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim(); diff --git a/app/assets/javascripts/pages/dashboard/groups/index/index.js b/app/assets/javascripts/pages/dashboard/groups/index/index.js index 79987642796..b9277106a71 100644 --- a/app/assets/javascripts/pages/dashboard/groups/index/index.js +++ b/app/assets/javascripts/pages/dashboard/groups/index/index.js @@ -1,3 +1,5 @@ import initGroupsList from '~/groups'; -document.addEventListener('DOMContentLoaded', initGroupsList); +document.addEventListener('DOMContentLoaded', () => { + initGroupsList(); +}); diff --git a/app/assets/javascripts/pages/groups/show/group_tabs.js b/app/assets/javascripts/pages/groups/show/group_tabs.js new file mode 100644 index 00000000000..c6fe61d2bd9 --- /dev/null +++ b/app/assets/javascripts/pages/groups/show/group_tabs.js @@ -0,0 +1,136 @@ +import $ from 'jquery'; +import { removeParams } from '~/lib/utils/url_utility'; +import createGroupTree from '~/groups'; +import { + ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, + ACTIVE_TAB_SHARED, + ACTIVE_TAB_ARCHIVED, + CONTENT_LIST_CLASS, + GROUPS_LIST_HOLDER_CLASS, + GROUPS_FILTER_FORM_CLASS, +} from '~/groups/constants'; +import UserTabs from '~/pages/users/user_tabs'; +import GroupFilterableList from '~/groups/groups_filterable_list'; + +export default class GroupTabs extends UserTabs { + constructor({ defaultAction = 'subgroups_and_projects', action, parentEl }) { + super({ defaultAction, action, parentEl }); + } + + bindEvents() { + this.$parentEl + .off('shown.bs.tab', '.nav-links a[data-toggle="tab"]') + .on('shown.bs.tab', '.nav-links a[data-toggle="tab"]', event => this.tabShown(event)); + } + + tabShown(event) { + const $target = $(event.target); + const action = $target.data('action') || $target.data('targetSection'); + const source = $target.attr('href') || $target.data('targetPath'); + + document.querySelector(GROUPS_FILTER_FORM_CLASS).action = source; + + this.setTab(action); + return this.setCurrentAction(source); + } + + setTab(action) { + const loadableActions = [ + ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, + ACTIVE_TAB_SHARED, + ACTIVE_TAB_ARCHIVED, + ]; + this.enableSearchBar(action); + this.action = action; + + if (this.loaded[action]) { + return; + } + + if (loadableActions.includes(action)) { + this.cleanFilterState(); + this.loadTab(action); + } + } + + loadTab(action) { + const elId = `js-groups-${action}-tree`; + const endpoint = this.getEndpoint(action); + + this.toggleLoading(true); + + createGroupTree(elId, endpoint, action); + this.loaded[action] = true; + + this.toggleLoading(false); + } + + getEndpoint(action) { + const { endpointsDefault, endpointsShared } = this.$parentEl.data(); + let endpoint; + + switch (action) { + case ACTIVE_TAB_ARCHIVED: + endpoint = `${endpointsDefault}?archived=only`; + break; + case ACTIVE_TAB_SHARED: + endpoint = endpointsShared; + break; + default: + // ACTIVE_TAB_SUBGROUPS_AND_PROJECTS + endpoint = endpointsDefault; + break; + } + + return endpoint; + } + + enableSearchBar(action) { + const containerEl = document.getElementById(action); + const form = document.querySelector(GROUPS_FILTER_FORM_CLASS); + const filter = form.querySelector('.js-groups-list-filter'); + const holder = containerEl.querySelector(GROUPS_LIST_HOLDER_CLASS); + const dataEl = containerEl.querySelector(CONTENT_LIST_CLASS); + const endpoint = this.getEndpoint(action); + + if (!dataEl) { + return; + } + + const { dataset } = dataEl; + const opts = { + form, + filter, + holder, + filterEndpoint: endpoint || dataset.endpoint, + pagePath: null, + dropdownSel: '.js-group-filter-dropdown-wrap', + filterInputField: 'filter', + action, + }; + + if (!this.loaded[action]) { + const filterableList = new GroupFilterableList(opts); + filterableList.initSearch(); + } + } + + cleanFilterState() { + const values = Object.values(this.loaded); + const loadedTabs = values.filter(e => e === true); + + if (!loadedTabs.length) { + return; + } + + const newState = removeParams(['page'], window.location.search); + + window.history.replaceState( + { + url: newState, + }, + document.title, + newState, + ); + } +} diff --git a/app/assets/javascripts/pages/groups/show/index.js b/app/assets/javascripts/pages/groups/show/index.js index d7b35d2b26b..5b8c2ae7e81 100644 --- a/app/assets/javascripts/pages/groups/show/index.js +++ b/app/assets/javascripts/pages/groups/show/index.js @@ -1,14 +1,22 @@ /* eslint-disable no-new */ +import { getPagePath } from '~/lib/utils/common_utils'; +import { ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED } from '~/groups/constants'; import NewGroupChild from '~/groups/new_group_child'; import notificationsDropdown from '~/notifications_dropdown'; import NotificationsForm from '~/notifications_form'; import ProjectsList from '~/projects_list'; import ShortcutsNavigation from '~/shortcuts_navigation'; -import initGroupsList from '~/groups'; +import GroupTabs from './group_tabs'; document.addEventListener('DOMContentLoaded', () => { const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup'); + const loadableActions = [ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED]; + const paths = window.location.pathname.split('/'); + const subpath = paths[paths.length - 1]; + const action = loadableActions.includes(subpath) ? subpath : getPagePath(1); + + new GroupTabs({ parentEl: '.groups-listing', action }); new ShortcutsNavigation(); new NotificationsForm(); notificationsDropdown(); @@ -17,6 +25,4 @@ document.addEventListener('DOMContentLoaded', () => { if (newGroupChildWrapper) { new NewGroupChild(newGroupChildWrapper); } - - initGroupsList(); }); diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js index cc0e6553e83..9c074b74c3b 100644 --- a/app/assets/javascripts/pages/projects/index.js +++ b/app/assets/javascripts/pages/projects/index.js @@ -1,4 +1,4 @@ -import gcpSignupOffer from '~/clusters/components/gcp_signup_offer'; +import initDismissableCallout from '~/dismissable_callout'; import initGkeDropdowns from '~/projects/gke_cluster_dropdowns'; import Project from './project'; import ShortcutsNavigation from '../../shortcuts_navigation'; @@ -12,7 +12,7 @@ document.addEventListener('DOMContentLoaded', () => { ]; if (newClusterViews.indexOf(page) > -1) { - gcpSignupOffer(); + initDismissableCallout('.gcp-signup-offer'); initGkeDropdowns(); } diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index fdcbcc236c1..34a13eb3251 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -61,6 +61,13 @@ export default class Project { .remove(); return e.preventDefault(); }); + $('.hide-auto-devops-implicitly-enabled-banner').on('click', function(e) { + const projectId = $(this).data('project-id'); + const cookieKey = `hide_auto_devops_implicitly_enabled_banner_${projectId}`; + Cookies.set(cookieKey, 'false'); + $(this).parents('.auto-devops-implicitly-enabled-banner').remove(); + return e.preventDefault(); + }); Project.projectSelectDropdown(); } 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 0289209ff1e..75cb6374ad5 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,12 +1,8 @@ <script> import _ from 'underscore'; -import GlModal from '~/vue_shared/components/gl_modal.vue'; import { s__, sprintf } from '~/locale'; export default { - components: { - GlModal, - }, props: { deleteWikiUrl: { type: String, @@ -25,6 +21,9 @@ export default { }, }, computed: { + modalId() { + return 'delete-wiki-modal'; + }, message() { return s__('WikiPageConfirmDelete|Are you sure you want to delete this page?'); }, @@ -47,31 +46,41 @@ export default { </script> <template> - <gl-modal - id="delete-wiki-modal" - :header-title-text="title" - :footer-primary-button-text="s__('WikiPageConfirmDelete|Delete page')" - footer-primary-button-variant="danger" - @submit="onSubmit" - > - {{ message }} - <form - ref="form" - :action="deleteWikiUrl" - method="post" - class="js-requires-input" + <div class="d-inline-block"> + <button + v-gl-modal="modalId" + type="button" + class="btn btn-danger" + > + {{ __('Delete') }} + </button> + <gl-ui-modal + :title="title" + :ok-title="s__('WikiPageConfirmDelete|Delete page')" + :modal-id="modalId" + title-tag="h4" + ok-variant="danger" + @ok="onSubmit" > - <input - ref="method" - type="hidden" - name="_method" - value="delete" - /> - <input - :value="csrfToken" - type="hidden" - name="authenticity_token" - /> - </form> - </gl-modal> + {{ message }} + <form + ref="form" + :action="deleteWikiUrl" + method="post" + class="js-requires-input" + > + <input + ref="method" + type="hidden" + name="_method" + value="delete" + /> + <input + :value="csrfToken" + type="hidden" + name="authenticity_token" + /> + </form> + </gl-ui-modal> + </div> </template> diff --git a/app/assets/javascripts/pages/projects/wikis/index.js b/app/assets/javascripts/pages/projects/wikis/index.js index 0a0fe3fc137..b0a323a71cd 100644 --- a/app/assets/javascripts/pages/projects/wikis/index.js +++ b/app/assets/javascripts/pages/projects/wikis/index.js @@ -14,15 +14,15 @@ document.addEventListener('DOMContentLoaded', () => { new ZenMode(); // eslint-disable-line no-new new GLForm($('.wiki-form')); // eslint-disable-line no-new - const deleteWikiButton = document.getElementById('delete-wiki-button'); + const deleteWikiModalWrapperEl = document.getElementById('delete-wiki-modal-wrapper'); - if (deleteWikiButton) { + if (deleteWikiModalWrapperEl) { Vue.use(Translate); - const { deleteWikiUrl, pageTitle } = deleteWikiButton.dataset; - const deleteWikiModalEl = document.getElementById('delete-wiki-modal'); - const deleteModal = new Vue({ // eslint-disable-line - el: deleteWikiModalEl, + const { deleteWikiUrl, pageTitle } = deleteWikiModalWrapperEl.dataset; + + new Vue({ // eslint-disable-line no-new + el: deleteWikiModalWrapperEl, data: { deleteWikiUrl: '', }, diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 1952dd453f4..e27f195c9b0 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -1,12 +1,10 @@ <script> import _ from 'underscore'; -import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; import StageColumnComponent from './stage_column_component.vue'; export default { components: { StageColumnComponent, - LoadingIcon, }, props: { isLoading: { @@ -59,9 +57,9 @@ export default { <div class="build-content middle-block js-pipeline-graph"> <div class="pipeline-visualization pipeline-graph pipeline-tab-content"> <div class="text-center"> - <loading-icon + <gl-loading-icon v-if="isLoading" - size="3" + :size="3" /> </div> diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index 001eaeaa065..1f9187c3d65 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -1,13 +1,11 @@ <script> import ciHeader from '../../vue_shared/components/header_ci_component.vue'; import eventHub from '../event_hub'; -import loadingIcon from '../../vue_shared/components/loading_icon.vue'; export default { name: 'PipelineHeaderSection', components: { ciHeader, - loadingIcon, }, props: { pipeline: { @@ -89,9 +87,9 @@ export default { item-name="Pipeline" @actionClicked="postAction" /> - <loading-icon + <gl-loading-icon v-if="isLoading" - size="2" + :size="2" class="prepend-top-default append-bottom-default" /> </div> diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines.vue index c9d2dc3a3c5..ea526cf1309 100644 --- a/app/assets/javascripts/pipelines/components/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines.vue @@ -319,10 +319,10 @@ export default { <div class="content-list pipelines"> - <loading-icon + <gl-loading-icon v-if="stateToRender === $options.stateMap.loading" :label="s__('Pipelines|Loading Pipelines')" - size="3" + :size="3" class="prepend-top-20" /> diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_actions.vue index 1c8d7303c52..017dd560621 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_actions.vue @@ -1,6 +1,5 @@ <script> import eventHub from '../event_hub'; -import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import icon from '../../vue_shared/components/icon.vue'; import tooltip from '../../vue_shared/directives/tooltip'; @@ -9,7 +8,6 @@ export default { tooltip, }, components: { - loadingIcon, icon, }, props: { @@ -60,7 +58,7 @@ export default { class="fa fa-caret-down" aria-hidden="true"> </i> - <loading-icon v-if="isLoading" /> + <gl-loading-icon v-if="isLoading" /> </button> <ul class="dropdown-menu dropdown-menu-right"> diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue index 29b347824de..a39cc265601 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue @@ -132,10 +132,8 @@ export default { if (this.pipeline.ref) { return Object.keys(this.pipeline.ref).reduce((accumulator, prop) => { if (prop === 'path') { - // eslint-disable-next-line no-param-reassign accumulator.ref_url = this.pipeline.ref[prop]; } else { - // eslint-disable-next-line no-param-reassign accumulator[prop] = this.pipeline.ref[prop]; } return accumulator; diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue index c7df69c69ed..3e13bad9a0b 100644 --- a/app/assets/javascripts/pipelines/components/stage.vue +++ b/app/assets/javascripts/pipelines/components/stage.vue @@ -18,14 +18,12 @@ import Flash from '../../flash'; import axios from '../../lib/utils/axios_utils'; import eventHub from '../event_hub'; import Icon from '../../vue_shared/components/icon.vue'; -import LoadingIcon from '../../vue_shared/components/loading_icon.vue'; import JobComponent from './graph/job_component.vue'; import tooltip from '../../vue_shared/directives/tooltip'; import { PIPELINES_TABLE } from '../constants'; export default { components: { - LoadingIcon, Icon, JobComponent, }, @@ -191,7 +189,7 @@ export default { class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container" aria-labelledby="stageDropdown" > - <loading-icon v-if="isLoading"/> + <gl-loading-icon v-if="isLoading"/> <ul v-else class="js-builds-dropdown-list scrollable-menu" diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js index 2cb558b0dec..8929b397f6c 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines.js @@ -4,7 +4,6 @@ import Flash from '../../flash'; import Poll from '../../lib/utils/poll'; import EmptyState from '../components/empty_state.vue'; import SvgBlankState from '../components/blank_state.vue'; -import LoadingIcon from '../../vue_shared/components/loading_icon.vue'; import PipelinesTableComponent from '../components/pipelines_table.vue'; import eventHub from '../event_hub'; import { CANCEL_REQUEST } from '../constants'; @@ -14,7 +13,6 @@ export default { PipelinesTableComponent, SvgBlankState, EmptyState, - LoadingIcon, }, data() { return { diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_dropdown_mixin.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_dropdown_mixin.js index c15d8ba49e1..d5266544307 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_dropdown_mixin.js +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_dropdown_mixin.js @@ -1,5 +1,4 @@ import _ from 'underscore'; -import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; 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'; @@ -9,7 +8,6 @@ import store from '../store'; export default { store, components: { - LoadingIcon, DropdownButton, DropdownSearchInput, DropdownHiddenInput, diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue index d4497924ad8..2c02f436b69 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue @@ -126,7 +126,7 @@ export default { </ul> </div> <div class="dropdown-loading"> - <loading-icon /> + <gl-loading-icon /> </div> </div> </div> diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue index 08d0a122579..fc17e2fab49 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue @@ -187,7 +187,7 @@ export default { </ul> </div> <div class="dropdown-loading"> - <loading-icon /> + <gl-loading-icon /> </div> </div> </div> diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue index b5476684c6a..ca7c79f75f0 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue @@ -100,7 +100,7 @@ export default { </ul> </div> <div class="dropdown-loading"> - <loading-icon /> + <gl-loading-icon /> </div> </div> </div> diff --git a/app/assets/javascripts/projects/project_import_gitlab_project.js b/app/assets/javascripts/projects/project_import_gitlab_project.js index 4e20fce1460..fbef3a0b059 100644 --- a/app/assets/javascripts/projects/project_import_gitlab_project.js +++ b/app/assets/javascripts/projects/project_import_gitlab_project.js @@ -1,9 +1,19 @@ import $ from 'jquery'; import { getParameterValues } from '../lib/utils/url_utility'; +import projectNew from './project_new'; export default () => { - const path = getParameterValues('path')[0]; + const pathParam = getParameterValues('path')[0]; + const nameParam = getParameterValues('name')[0]; + const $projectPath = $('.js-path-name'); + const $projectName = $('.js-project-name'); - // get the path url and append it in the inputS - $('.js-path-name').val(path); + // get the path url and append it in the input + $projectPath.val(pathParam); + + // get the project name from the URL and set it as input value + $projectName.val(nameParam); + + // generate slug when project name changes + $projectName.keyup(() => projectNew.onProjectNameChange($projectName, $projectPath)); }; diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 04badad0f34..8a079b4b38a 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils'; +import { slugifyWithHyphens } from '../lib/utils/text_utility'; let hasUserDefinedProjectPath = false; @@ -29,18 +30,23 @@ const deriveProjectPathFromUrl = ($projectImportUrl) => { } }; +const onProjectNameChange = ($projectNameInput, $projectPathInput) => { + const slug = slugifyWithHyphens($projectNameInput.val()); + $projectPathInput.val(slug); +}; + const bindEvents = () => { const $newProjectForm = $('#new_project'); const $projectImportUrl = $('#project_import_url'); - const $projectPath = $('#project_path'); + const $projectPath = $('.tab-pane.active #project_path'); const $useTemplateBtn = $('.template-button > input'); const $projectFieldsForm = $('.project-fields-form'); const $selectedTemplateText = $('.selected-template'); const $changeTemplateBtn = $('.change-template'); const $selectedIcon = $('.selected-icon'); - const $templateProjectNameInput = $('#template-project-name #project_path'); const $pushNewProjectTipTrigger = $('.push-new-project-tip'); const $projectTemplateButtons = $('.project-templates-buttons'); + const $projectName = $('.tab-pane.active #project_name'); if ($newProjectForm.length !== 1) { return; @@ -57,7 +63,8 @@ const bindEvents = () => { $('.btn_import_gitlab_project').on('click', () => { const importHref = $('a.btn_import_gitlab_project').attr('href'); - $('.btn_import_gitlab_project').attr('href', `${importHref}?namespace_id=${$('#project_namespace_id').val()}&path=${$projectPath.val()}`); + $('.btn_import_gitlab_project') + .attr('href', `${importHref}?namespace_id=${$('#project_namespace_id').val()}&name=${$projectName.val()}&path=${$projectPath.val()}`); }); if ($pushNewProjectTipTrigger) { @@ -111,7 +118,15 @@ const bindEvents = () => { const selectedTemplate = templates[value]; $selectedTemplateText.text(selectedTemplate.text); $(selectedTemplate.icon).clone().addClass('d-block').appendTo($selectedIcon); - $templateProjectNameInput.focus(); + + const $activeTabProjectName = $('.tab-pane.active #project_name'); + const $activeTabProjectPath = $('.tab-pane.active #project_path'); + $activeTabProjectName.focus(); + $activeTabProjectName + .keyup(() => { + onProjectNameChange($activeTabProjectName, $activeTabProjectPath); + hasUserDefinedProjectPath = $activeTabProjectPath.val().trim().length > 0; + }); } $useTemplateBtn.on('change', chooseTemplate); @@ -131,9 +146,15 @@ const bindEvents = () => { }); $projectImportUrl.keyup(() => deriveProjectPathFromUrl($projectImportUrl)); + + $projectName.keyup(() => { + onProjectNameChange($projectName, $projectPath); + hasUserDefinedProjectPath = $projectPath.val().trim().length > 0; + }); }; export default { bindEvents, deriveProjectPathFromUrl, + onProjectNameChange, }; 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 1c1e17563a1..120b4fc2f2b 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,7 +1,6 @@ <script> import Visibility from 'visibilityjs'; import ciIcon from '~/vue_shared/components/ci_icon.vue'; -import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import Poll from '~/lib/utils/poll'; import Flash from '~/flash'; import { s__, sprintf } from '~/locale'; @@ -14,7 +13,6 @@ export default { }, components: { ciIcon, - loadingIcon, }, props: { endpoint: { @@ -100,10 +98,10 @@ export default { </script> <template> <div class="ci-status-link"> - <loading-icon + <gl-loading-icon v-if="isLoading" + :size="3" label="Loading pipeline status" - size="3" /> <a v-else diff --git a/app/assets/javascripts/registry/components/app.vue b/app/assets/javascripts/registry/components/app.vue index 31f88675912..7e2287ac4db 100644 --- a/app/assets/javascripts/registry/components/app.vue +++ b/app/assets/javascripts/registry/components/app.vue @@ -1,7 +1,6 @@ <script> import { mapGetters, mapActions } from 'vuex'; import Flash from '../../flash'; - import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import store from '../stores'; import collapsibleContainer from './collapsible_container.vue'; import { errorMessages, errorMessagesTypes } from '../constants'; @@ -10,7 +9,6 @@ name: 'RegistryListApp', components: { collapsibleContainer, - loadingIcon, }, props: { endpoint: { @@ -42,9 +40,9 @@ </script> <template> <div> - <loading-icon + <gl-loading-icon v-if="isLoading" - size="3" + :size="3" /> <collapsible-container diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue index 4116c4a0489..d4c4d779d44 100644 --- a/app/assets/javascripts/registry/components/collapsible_container.vue +++ b/app/assets/javascripts/registry/components/collapsible_container.vue @@ -2,16 +2,15 @@ import { mapActions } from 'vuex'; import Flash from '../../flash'; import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; - import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import tooltip from '../../vue_shared/directives/tooltip'; import tableRegistry from './table_registry.vue'; import { errorMessages, errorMessagesTypes } from '../constants'; + import { __ } from '../../locale'; export default { name: 'CollapsibeContainerRegisty', components: { clipboardButton, - loadingIcon, tableRegistry, }, directives: { @@ -46,7 +45,10 @@ handleDeleteRepository() { this.deleteRepo(this.repo) - .then(() => this.fetchRepos()) + .then(() => { + Flash(__('This container registry has been scheduled for deletion.'), 'notice'); + this.fetchRepos(); + }) .catch(() => this.showError(errorMessagesTypes.DELETE_REPO)); }, @@ -103,10 +105,10 @@ </div> </div> - <loading-icon + <gl-loading-icon v-if="repo.isLoading" + :size="2" class="append-bottom-20" - size="2" /> <div diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/reports/components/summary_row.vue index 4456d84c968..51188981bed 100644 --- a/app/assets/javascripts/reports/components/summary_row.vue +++ b/app/assets/javascripts/reports/components/summary_row.vue @@ -1,6 +1,5 @@ <script> import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; import Popover from '~/vue_shared/components/help_popover.vue'; /** @@ -15,7 +14,6 @@ export default { name: 'ReportSummaryRow', components: { CiIcon, - LoadingIcon, Popover, }, props: { @@ -46,7 +44,7 @@ export default { <template> <div class="report-block-list-issue report-block-list-issue-parent"> <div class="report-block-list-icon append-right-10 prepend-left-5"> - <loading-icon + <gl-loading-icon v-if="statusIcon === 'loading'" css-class="report-block-list-loading-icon" /> diff --git a/app/assets/javascripts/reports/store/mutations.js b/app/assets/javascripts/reports/store/mutations.js index 1983a8c9e56..b88bff97075 100644 --- a/app/assets/javascripts/reports/store/mutations.js +++ b/app/assets/javascripts/reports/store/mutations.js @@ -1,4 +1,3 @@ -/* eslint-disable no-param-reassign */ import * as types from './mutation_types'; export default { diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index 56d57f6aac8..286a16f7bbf 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -1,7 +1,6 @@ <script> import { __, n__, sprintf } from '~/locale'; import tooltip from '~/vue_shared/directives/tooltip'; - import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; export default { @@ -9,7 +8,6 @@ tooltip, }, components: { - loadingIcon, userAvatarImage, }, props: { @@ -93,7 +91,7 @@ aria-hidden="true" > </i> - <loading-icon + <gl-loading-icon v-if="loading" class="js-participants-collapsed-loading-icon" /> @@ -105,7 +103,7 @@ </span> </div> <div class="title hide-collapsed"> - <loading-icon + <gl-loading-icon v-if="loading" :inline="true" class="js-participants-expanded-loading-icon" diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue index ffaed9c7193..a6b3a674952 100644 --- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue @@ -3,7 +3,6 @@ import { __ } from '~/locale'; import tooltip from '~/vue_shared/directives/tooltip'; import Icon from '~/vue_shared/components/icon.vue'; -import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; const MARK_TEXT = __('Mark todo as done'); const TODO_TEXT = __('Add todo'); @@ -14,7 +13,6 @@ export default { }, components: { Icon, - LoadingIcon, }, props: { issuableId: { @@ -90,7 +88,7 @@ export default { > {{ buttonLabel }} </span> - <loading-icon + <gl-loading-icon v-show="isActionActive" :inline="true" /> diff --git a/app/assets/javascripts/usage_ping_consent.js b/app/assets/javascripts/usage_ping_consent.js new file mode 100644 index 00000000000..ae3fde190e3 --- /dev/null +++ b/app/assets/javascripts/usage_ping_consent.js @@ -0,0 +1,30 @@ +import $ from 'jquery'; +import axios from './lib/utils/axios_utils'; +import Flash, { hideFlash } from './flash'; +import { convertPermissionToBoolean } from './lib/utils/common_utils'; + +export default () => { + $('body').on('click', '.js-usage-consent-action', (e) => { + e.preventDefault(); + e.stopImmediatePropagation(); // overwrite rails listener + + const { url, checkEnabled, pingEnabled } = e.target.dataset; + const data = { + application_setting: { + version_check_enabled: convertPermissionToBoolean(checkEnabled), + usage_ping_enabled: convertPermissionToBoolean(pingEnabled), + }, + }; + + const hideConsentMessage = () => hideFlash(document.querySelector('.ping-consent-message')); + + axios.put(url, data) + .then(() => { + hideConsentMessage(); + }) + .catch(() => { + hideConsentMessage(); + Flash('Something went wrong. Try again later.'); + }); + }); +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue index 9aff95dcfec..035ae791a1d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue @@ -1,11 +1,9 @@ <script> import ciIcon from '../../vue_shared/components/ci_icon.vue'; - import loadingIcon from '../../vue_shared/components/loading_icon.vue'; export default { components: { ciIcon, - loadingIcon, }, props: { status: { @@ -37,7 +35,7 @@ v-if="isLoading" class="mr-widget-icon" > - <loading-icon /> + <gl-loading-icon /> </div> <ci-icon diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue index 2133124347c..01294d5b40c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue @@ -1,5 +1,4 @@ <script> - import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import eventHub from '../../event_hub'; import statusIcon from '../mr_widget_status_icon.vue'; @@ -7,7 +6,6 @@ name: 'MRWidgetAutoMergeFailed', components: { statusIcon, - loadingIcon, }, props: { mr: { @@ -44,7 +42,7 @@ class="btn btn-sm btn-default" @click="refreshWidget" > - <loading-icon + <gl-loading-icon v-if="isRefreshing" :inline="true" /> 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 1a444c04a1d..2f2394371ef 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,7 +1,6 @@ <script> import Flash from '~/flash'; import tooltip from '~/vue_shared/directives/tooltip'; - import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import { s__, __ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import MrWidgetAuthorTime from '../../components/mr_widget_author_time.vue'; @@ -15,7 +14,6 @@ }, components: { MrWidgetAuthorTime, - loadingIcon, statusIcon, ClipboardButton, }, @@ -195,7 +193,7 @@ </button> </p> <p v-if="shouldShowSourceBranchRemoving"> - <loading-icon :inline="true" /> + <gl-loading-icon :inline="true" /> <span> {{ s__("mrWidget|The source branch is being removed") }} </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index 2d8c3d6be87..f31c7a3edb8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -2,14 +2,12 @@ import simplePoll from '../../../lib/utils/simple_poll'; import eventHub from '../../event_hub'; import statusIcon from '../mr_widget_status_icon.vue'; - import loadingIcon from '../../../vue_shared/components/loading_icon.vue'; import Flash from '../../../flash'; export default { name: 'MRWidgetRebase', components: { statusIcon, - loadingIcon, }, props: { mr: { @@ -115,7 +113,7 @@ js-toggle-container accept-action media space-children" class="btn btn-sm btn-reopen btn-success qa-mr-rebase-button" @click="rebase" > - <loading-icon v-if="isMakingRequest" /> + <gl-loading-icon v-if="isMakingRequest" /> Rebase </button> <span 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 af5ebcdc40a..31087017968 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue @@ -1,11 +1,7 @@ <script> import { __ } from '~/locale'; -import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; export default { - components: { - LoadingIcon, - }, props: { isDisabled: { type: Boolean, @@ -34,7 +30,7 @@ export default { data-toggle="dropdown" aria-expanded="false" > - <loading-icon + <gl-loading-icon v-show="isLoading" :inline="true" /> diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue index 878c805ada5..408f7d7965f 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/file_icon.vue @@ -1,6 +1,5 @@ <script> import getIconForFile from './file_icon/file_icon_map'; -import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import icon from '../../vue_shared/components/icon.vue'; /* This is a re-usable vue component for rendering a svg sprite @@ -17,7 +16,6 @@ import icon from '../../vue_shared/components/icon.vue'; */ export default { components: { - loadingIcon, icon, }, props: { @@ -84,7 +82,7 @@ export default { :size="size" css-classes="folder-icon" /> - <loading-icon + <gl-loading-icon v-if="loading" :inline="true" /> diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index 49fbce75110..18f5ce53bb1 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -1,6 +1,5 @@ <script> import CiIconBadge from './ci_badge_link.vue'; -import LoadingIcon from './loading_icon.vue'; import TimeagoTooltip from './time_ago_tooltip.vue'; import tooltip from '../directives/tooltip'; import UserAvatarImage from './user_avatar/user_avatar_image.vue'; @@ -15,7 +14,6 @@ import UserAvatarImage from './user_avatar/user_avatar_image.vue'; export default { components: { CiIconBadge, - LoadingIcon, TimeagoTooltip, UserAvatarImage, }, diff --git a/app/assets/javascripts/vue_shared/components/loading_button.vue b/app/assets/javascripts/vue_shared/components/loading_button.vue index 2ff0c056b9c..4cbd3e6429d 100644 --- a/app/assets/javascripts/vue_shared/components/loading_button.vue +++ b/app/assets/javascripts/vue_shared/components/loading_button.vue @@ -17,12 +17,7 @@ */ - import loadingIcon from './loading_icon.vue'; - export default { - components: { - loadingIcon, - }, props: { loading: { type: Boolean, @@ -60,7 +55,7 @@ @click="onClick" > <transition name="fade"> - <loading-icon + <gl-loading-icon v-if="loading" :inline="true" :class="{ diff --git a/app/assets/javascripts/vue_shared/components/loading_icon.vue b/app/assets/javascripts/vue_shared/components/loading_icon.vue deleted file mode 100644 index db22c5f02cd..00000000000 --- a/app/assets/javascripts/vue_shared/components/loading_icon.vue +++ /dev/null @@ -1,45 +0,0 @@ -<script> - export default { - props: { - label: { - type: String, - required: false, - default: 'Loading', - }, - - size: { - type: String, - required: false, - default: '1', - }, - - inline: { - type: Boolean, - required: false, - default: false, - }, - }, - - computed: { - rootElementType() { - return this.inline ? 'span' : 'div'; - }, - cssClass() { - return `fa-${this.size}x`; - }, - }, - }; -</script> -<template> - <component - :is="rootElementType" - class="loading-container text-center"> - <i - :class="cssClass" - :aria-label="label" - class="fa fa-spin fa-spinner" - aria-hidden="true" - > - </i> - </component> -</template> diff --git a/app/assets/javascripts/vue_shared/components/pagination_links.vue b/app/assets/javascripts/vue_shared/components/pagination_links.vue new file mode 100644 index 00000000000..1f2a679c145 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/pagination_links.vue @@ -0,0 +1,34 @@ +<script> +import { s__ } from '../../locale'; + +export default { + props: { + change: { + type: Function, + required: true, + }, + pageInfo: { + type: Object, + required: true, + }, + }, + firstText: s__('Pagination|« First'), + prevText: s__('Pagination|Prev'), + nextText: s__('Pagination|Next'), + lastText: s__('Pagination|Last »'), +}; +</script> + +<template> + <gl-pagination + v-bind="$attrs" + :change="change" + :page="pageInfo.page" + :per-page="pageInfo.perPage" + :total-items="pageInfo.total" + :first-text="$options.firstText" + :prev-text="$options.prevText" + :next-text="$options.nextText" + :last-text="$options.lastText" + /> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue index 74998a4787d..9d757b27edc 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue @@ -1,6 +1,5 @@ <script> import datePicker from '../pikaday.vue'; - import loadingIcon from '../loading_icon.vue'; import toggleSidebar from './toggle_sidebar.vue'; import collapsedCalendarIcon from './collapsed_calendar_icon.vue'; import { dateInWords } from '../../../lib/utils/datetime_utility'; @@ -10,7 +9,6 @@ components: { datePicker, toggleSidebar, - loadingIcon, collapsedCalendarIcon, }, props: { @@ -112,7 +110,7 @@ /> <div class="title"> {{ label }} - <loading-icon + <gl-loading-icon v-if="isLoading" :inline="true" /> 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 a3fc358130f..3df286de129 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 @@ -3,7 +3,6 @@ import $ from 'jquery'; import { __ } from '~/locale'; import LabelsSelect from '~/labels_select'; import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; -import LoadingIcon from '../../loading_icon.vue'; import DropdownTitle from './dropdown_title.vue'; import DropdownValue from './dropdown_value.vue'; @@ -16,7 +15,6 @@ import DropdownCreateLabel from './dropdown_create_label.vue'; export default { components: { - LoadingIcon, DropdownTitle, DropdownValue, DropdownValueCollapsed, @@ -164,7 +162,7 @@ dropdown-menu-labels dropdown-menu-selectable" <dropdown-search-input/> <div class="dropdown-content"></div> <div class="dropdown-loading"> - <loading-icon /> + <gl-loading-icon /> </div> <dropdown-footer v-if="showCreate" diff --git a/app/assets/javascripts/vue_shared/components/toggle_button.vue b/app/assets/javascripts/vue_shared/components/toggle_button.vue index a897300b62b..5b9c51786d6 100644 --- a/app/assets/javascripts/vue_shared/components/toggle_button.vue +++ b/app/assets/javascripts/vue_shared/components/toggle_button.vue @@ -1,7 +1,6 @@ <script> import { s__ } from '../../locale'; import icon from './icon.vue'; - import loadingIcon from './loading_icon.vue'; const ICON_ON = 'status_success_borderless'; const ICON_OFF = 'status_failed_borderless'; @@ -11,7 +10,6 @@ export default { components: { icon, - loadingIcon, }, model: { @@ -78,7 +76,7 @@ class="project-feature-toggle" @click="toggleFeature" > - <loadingIcon class="loading-icon" /> + <gl-loading-icon class="loading-icon" /> <span class="toggle-icon"> <icon :name="toggleIcon" diff --git a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js index 73b9131e5ba..b9693892f45 100644 --- a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js +++ b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js @@ -28,7 +28,7 @@ Vue.http.interceptors.push((request, next) => { response.headers.forEach((value, key) => { headers[key] = value; }); - + // eslint-disable-next-line no-param-reassign response.headers = headers; }); }); diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 39ffabb3ea6..4ffb3e9ab42 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -27,7 +27,6 @@ @import 'framework/header'; @import 'framework/highlight'; @import 'framework/issue_box'; -@import 'framework/jquery'; @import 'framework/lists'; @import 'framework/logo'; @import 'framework/markdown_area'; diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss index 9dd0384a228..fcf282a7d7c 100644 --- a/app/assets/stylesheets/framework/avatar.scss +++ b/app/assets/stylesheets/framework/avatar.scss @@ -69,7 +69,7 @@ .identicon { text-align: center; vertical-align: top; - color: $identicon-fg-color; + color: $gl-gray-700; background-color: $gray-darker; // Sizes @@ -104,6 +104,7 @@ a { width: 100%; + height: 100%; display: flex; } diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index e91e830fcac..ab62ca07573 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -166,6 +166,10 @@ @include btn-outline($white-light, $red-500, $red-500, $red-500, $white-light, $red-600, $red-600, $red-700); } + &.btn-warning { + @include btn-outline($white-light, $orange-500, $orange-500, $orange-500, $white-light, $orange-600, $orange-600, $orange-700); + } + &.btn-primary, &.btn-info { @include btn-outline($white-light, $blue-500, $blue-500, $blue-500, $white-light, $blue-600, $blue-600, $blue-700); @@ -365,7 +369,7 @@ } .clone-dropdown-btn a { - color: $dropdown-link-color; + color: $gl-gray-700; &:hover { text-decoration: none; diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 72e27f9ad16..28dda65091d 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -43,7 +43,7 @@ color: $brand-info; } -.hint { font-style: italic; color: $hint-color; } +.hint { font-style: italic; color: $gl-gray-400; } .light { color: $gl-text-color; } .slead { @@ -70,13 +70,6 @@ pre { padding: 0; } - &.card.card-body-pre { - border: 1px solid $gray-darker; - background: $gray-light; - border-radius: 0; - color: $well-pre-color; - } - &.wrap { word-break: break-word; white-space: pre-wrap; @@ -121,49 +114,24 @@ hr { text-decoration: none; } -.back-link { - font-size: 14px; -} - table { a code { position: relative; top: -2px; margin-right: 3px; } - - td.permission-x { - background: $table-permission-x-bg !important; - text-align: center; - } } .loading { margin: 20px auto; height: 40px; - color: $loading-color; + color: $gl-gray-700; font-size: 32px; text-align: center; } -span.update-author { - display: block; - color: $update-author-color; - font-weight: $gl-font-weight-normal; - font-style: italic; - - strong { - font-weight: $gl-font-weight-bold; - font-style: normal; - } -} - -.field_with_errors { - display: inline; -} - p.time { - color: $time-color; + color: $gl-gray-400; font-size: 90%; margin: 30px 3px 3px 2px; } @@ -197,40 +165,11 @@ li.note { background-color: inherit; } -.project_member_show { - td:first-child { - color: $project-member-show-color; - } -} - -.rss-icon { - img { - width: 24px; - vertical-align: top; - } - - strong { - line-height: 24px; - } -} - .show-suppressed-diff, .show-all-commits { cursor: pointer; } -.git_error_tips { - @extend .col-lg-6; - text-align: left; - margin-top: 40px; - - pre { - background: $white-light; - border: 0; - font-size: 12px; - } -} - .error-message { padding: 10px; background: $red-400; @@ -258,7 +197,7 @@ li.note { .gitlab-promo { a { - color: $gl-promo-color; + color: $gl-gray-350; margin-right: 30px; } } @@ -271,19 +210,6 @@ li.note { } } -.control-group { - .controls { - span { - &.descr { - position: relative; - top: 2px; - left: 5px; - color: $control-group-descr-color; - } - } - } -} - img.emoji { height: 20px; vertical-align: top; @@ -302,12 +228,6 @@ img.emoji { margin-bottom: 10px; } -.side-filters { - fieldset { - margin-bottom: 15px; - } -} - .footer-links { margin-bottom: 20px; @@ -329,25 +249,6 @@ img.emoji { text-align: center; } -.header-with-avatar { - h3 { - margin: 0; - font-weight: $gl-font-weight-bold; - } - - .username { - font-size: 18px; - color: $username-color; - margin-top: 8px; - } - - .description { - font-size: $gl-font-size; - color: $description-color; - margin-top: 8px; - } -} - .dropzone .dz-preview .dz-progress { border-color: $border-color !important; @@ -386,16 +287,6 @@ img.emoji { } } -.content-separator { - margin-left: -$gl-padding; - margin-right: -$gl-padding; - border-top: 1px solid $border-color; -} - -.hide-bottom-border { - border-bottom: 0 !important; -} - .gl-accessibility { &:focus { display: flex; diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 8a224dc517e..8603714f709 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -607,25 +607,25 @@ width: 100%; min-height: 30px; padding: 0 7px; - color: $dropdown-input-color; + color: $gl-gray-700; line-height: 30px; border: 1px solid $dropdown-divider-color; border-radius: 2px; outline: 0; &:focus { - color: $dropdown-link-color; + color: $gl-gray-700; border-color: $blue-300; box-shadow: 0 0 4px $dropdown-input-focus-shadow; ~ .fa { - color: $dropdown-link-color; + color: $gl-gray-700; } } &:hover { ~ .fa { - color: $dropdown-link-color; + color: $gl-gray-700; } } } @@ -890,7 +890,7 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu { position: absolute; top: 13px; right: 25px; - color: $md-area-border; + color: $gray-100; } } @@ -929,7 +929,7 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu { &:hover { .frequent-items-item-avatar-container .avatar { - border-color: $md-area-border; + border-color: $gray-100; } } diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss index 6c50ea719d3..be85e03430e 100644 --- a/app/assets/stylesheets/framework/emojis.scss +++ b/app/assets/stylesheets/framework/emojis.scss @@ -6,3 +6,13 @@ gl-emoji { font-size: 1.4em; line-height: 1em; } + +.user-status-emoji { + margin-right: $gl-padding-4; + + gl-emoji { + font-size: 1em; + line-height: 16px; + vertical-align: baseline; + } +} diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 1d3512bbb4c..53f198b47c6 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -184,7 +184,7 @@ &.line-numbers { float: none; - border-left: 1px solid $blame-line-numbers-border; + border-left: 1px solid $gl-gray-100; i { float: none; diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index a52e6c4f6a7..e9b074236cc 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -92,8 +92,8 @@ display: -webkit-flex; display: flex; flex-shrink: 0; - margin-top: 5px; - margin-bottom: 5px; + margin-top: 4px; + margin-bottom: 4px; .selectable { display: -webkit-flex; diff --git a/app/assets/stylesheets/framework/jquery.scss b/app/assets/stylesheets/framework/jquery.scss deleted file mode 100644 index d1360a0c0eb..00000000000 --- a/app/assets/stylesheets/framework/jquery.scss +++ /dev/null @@ -1,15 +0,0 @@ -.ui-widget { - font-family: $regular-font; - font-size: $font-size-base; - - .ui-state-default { - border: 1px solid $white-light; - background: $white-light; - color: $jq-ui-default-color; - } - - .ui-state-highlight { - border: 0; - background: transparent; - } -} diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index d4bae4cb137..9218df9b40f 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -69,10 +69,14 @@ body { float: right; } - /* Center alert text and alert action links on smaller screens */ - @include media-breakpoint-down(sm) { - .alert { - text-align: center; + .flex-alert { + @include media-breakpoint-up(lg) { + display: flex; + + .alert-message { + flex: 1; + padding-right: 40px; + } } .alert-link-group { @@ -80,6 +84,13 @@ body { } } + @include media-breakpoint-down(sm) { + .alert-link-group { + float: none; + margin-top: $gl-padding-8; + } + } + /* Stripe the background colors so that adjacent alert-warnings are distinct from one another */ .alert-warning { transition: background-color 0.15s, border-color 0.15s; diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index d8391b59a8c..554e2b6720a 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -122,7 +122,7 @@ .markdown-area { border-radius: 0; background: $white-light; - border: 1px solid $md-area-border; + border: 1px solid $gray-100; min-height: 140px; max-height: 500px; padding: 5px; diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 7edb89ce6f3..7f37dd3de91 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -20,7 +20,7 @@ display: inline-block; overflow-x: auto; border: 0; - border-color: $md-area-border; + border-color: $gray-100; @supports (width: fit-content) { display: block; @@ -29,11 +29,11 @@ tr { th { - border-bottom: solid 2px $md-area-border; + border-bottom: solid 2px $gray-100; } td { - border-color: $md-area-border; + border-color: $gray-100; } } } diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index 3ae2c7078d6..381c0290d32 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -237,7 +237,7 @@ } .group-path { - color: $group-path-color; + color: $gl-gray-400; } } @@ -257,7 +257,7 @@ .namespace-result { .namespace-kind { - color: $namespace-kind-color; + color: $gl-gray-350; font-weight: $gl-font-weight-normal; } diff --git a/app/assets/stylesheets/framework/toggle.scss b/app/assets/stylesheets/framework/toggle.scss index 43aaf198609..8258da07e4d 100644 --- a/app/assets/stylesheets/framework/toggle.scss +++ b/app/assets/stylesheets/framework/toggle.scss @@ -31,7 +31,7 @@ height: 24px; cursor: pointer; user-select: none; - background: $feature-toggle-color-disabled; + background: $gl-gray-400; border-radius: 12px; padding: 3px; transition: all .4s ease; @@ -61,7 +61,7 @@ } .toggle-icon-svg { - fill: $feature-toggle-color-disabled; + fill: $gl-gray-400; } .toggle-status-checked { diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 9929f1bdebf..0c1b8b92de3 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -61,12 +61,12 @@ padding: 3px 5px; font-size: 11px; line-height: 10px; - color: $kdb-color; + color: $gl-gray-700; vertical-align: middle; background-color: $kdb-bg; border-width: 1px; border-style: solid; - border-color: $kdb-border $kdb-border $kdb-border-bottom; + border-color: $gl-gray-200 $gl-gray-200 $kdb-border-bottom; border-image: none; border-radius: 3px; box-shadow: 0 -1px 0 $kdb-shadow inset; diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index f5e7a84d082..f66782ab882 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -31,6 +31,14 @@ $gray-dark: darken($gray-light, $darken-dark-factor); $gray-darker: #eee; $gray-darkest: #c4c4c4; +$gl-gray-100: #dddddd; +$gl-gray-200: #cccccc; +$gl-gray-350: #aaaaaa; +$gl-gray-400: #999999; +$gl-gray-500: #777777; +$gl-gray-600: #666666; +$gl-gray-700: #555555; + $green-50: #f1fdf6; $green-100: #dcf5e7; $green-200: #b3e6c8; @@ -207,11 +215,6 @@ $list-border: rgba(0, 0, 0, 0.05); $list-text-height: 42px; /* - * Markdown - */ -$md-area-border: #ddd; - -/* * Code */ $code-font-size: 90%; @@ -241,7 +244,6 @@ $input-horizontal-padding: 12px; /* * Misc */ -$progress-color: #c0392b; $header-height: 40px; $ide-statusbar-height: 25px; $fixed-layout-width: 1280px; @@ -256,14 +258,7 @@ $btn-side-margin: 10px; $btn-sm-side-margin: 7px; $btn-margin-5: 5px; $sidebar-block-hover-color: #ebebeb; -$group-path-color: #999; -$namespace-kind-color: #aaa; -$panel-heading-link-color: #777; -$graph-author-email-color: #777; $count-arrow-border: #dce0e5; -$save-project-loader-color: #555; -$divergence-graph-bar-bg: #ccc; -$divergence-graph-separator-bg: #ccc; $general-hover-transition-duration: 100ms; $general-hover-transition-curve: linear; $highlight-changes-color: rgb(235, 255, 232); @@ -276,20 +271,8 @@ $project-title-row-height: 24px; /* * Common component specific colors */ -$hint-color: #999; -$well-pre-color: #555; -$loading-color: #555; -$update-author-color: #999; $user-mention-bg: rgba($blue-500, 0.044); $user-mention-bg-hover: rgba($blue-500, 0.15); -$time-color: #999; -$project-member-show-color: #aaa; -$gl-promo-color: #aaa; -$control-group-descr-color: #666; -$table-permission-x-bg: #d9edf7; -$username-color: #666; -$description-color: #666; -$profiler-border: #eee; /* tanuki logo colors */ $tanuki-red: #e24329; @@ -320,9 +303,7 @@ $line-select-yellow: #fcf8e7; $line-select-yellow-dark: #f0e2bd; $dark-diff-match-bg: rgba(255, 255, 255, 0.3); $dark-diff-match-color: rgba(255, 255, 255, 0.1); -$file-mode-changed: #777; $diff-image-info-color: gray; -$diff-swipe-border: #999; $diff-view-modes-color: gray; $diff-view-modes-border: #c1c1c1; $diff-jagged-border-gradient-color: darken($white-normal, 8%); @@ -342,12 +323,10 @@ $dropdown-width: 300px; $dropdown-min-height: 40px; $dropdown-max-height: 312px; $dropdown-vertical-offset: 4px; -$dropdown-link-color: #555; $dropdown-empty-row-bg: rgba(#000, 0.04); $dropdown-shadow-color: rgba(#000, 0.1); $dropdown-divider-color: rgba(#000, 0.1); $dropdown-title-btn-color: #bfbfbf; -$dropdown-input-color: #555; $dropdown-input-fa-color: #c7c7c7; $dropdown-input-focus-shadow: rgba($blue-300, 0.4); $dropdown-loading-bg: rgba(#fff, 0.6); @@ -420,15 +399,9 @@ $location-icon-color: #e7e9ed; $note-disabled-comment-color: #b2b2b2; $note-targe3-outside: #fffff0; $note-targe3-inside: #ffffd3; -$note-line2-border: #ddd; $note-icon-gutter-width: 55px; /* -* Zen -*/ -$zen-control-color: #555; - -/* * Identicon */ $identicon-red: #ffebee; @@ -437,7 +410,6 @@ $identicon-indigo: #e8eaf6; $identicon-blue: #e3f2fd; $identicon-teal: #e0f2f1; $identicon-orange: #fbe9e7; -$identicon-fg-color: #555555; /* * Calendar @@ -506,16 +478,8 @@ $common-gray-light: #bbb; $common-gray-dark: #444; /* -* Events -*/ -$events-pre-color: #777; -$events-note-icon-color: #777; -$events-body-border: #ddd; - -/* * Files */ -$blame-line-numbers-border: #ddd; $logs-li-color: #888; $logs-p-color: #333; @@ -534,8 +498,6 @@ $input-short-md-width: 280px; * Help */ $document-index-color: #888; -$help-shortcut-color: #999; -$help-shortcut-mapping-color: #555; $help-shortcut-header-color: #333; /* @@ -546,12 +508,6 @@ $issues-today-border: #e1e8d5; $compare-display-color: #888; /* -* jQuery UI -*/ -$jq-ui-border: #ddd; -$jq-ui-default-color: #777; - -/* * Label */ $label-font-size: 12px; @@ -575,34 +531,19 @@ $fade-mask-transition-curve: ease-in-out; $login-brand-holder-color: #888; /* -* Nav -*/ -$nav-link-gray: #959494; -$nav-toggle-gray: #666; - -/* -* Notify -*/ -$notify-details: #777; -$notify-footer: #777; - -/* * Projects */ $project-option-descr-color: #54565b; -$project-breadcrumb-color: #999; $project-network-controls-color: #888; $feature-toggle-color: #fff; $feature-toggle-text-color: #fff; -$feature-toggle-color-disabled: #999; $feature-toggle-color-enabled: #4a8bee; /* Stat Graph */ $stat-graph-common-bg: #f3f3f3; -$stat-graph-axis-fill: #aaa; $stat-graph-selection-fill: #333; $stat-graph-selection-stroke: #333; @@ -613,17 +554,9 @@ $select2-drop-shadow1: rgba(76, 86, 103, 0.247059); $select2-drop-shadow2: rgba(31, 37, 50, 0.317647); /* -* Todo -*/ -$todo-body-pre-color: #777; -$todo-body-border: #ddd; - -/* * Typography */ $kdb-bg: #fcfcfc; -$kdb-color: #555; -$kdb-border: #ccc; $kdb-border-bottom: #bbb; $kdb-shadow: #bbb; $body-text-shadow: rgba(255, 255, 255, 0.01); @@ -632,7 +565,6 @@ $body-text-shadow: rgba(255, 255, 255, 0.01); * UI Dev Kit */ $ui-dev-kit-example-color: #bbb; -$ui-dev-kit-example-border: #ddd; /* Pipeline Graph @@ -666,12 +598,10 @@ $dropdown-animation-timing: cubic-bezier(0.23, 1, 0.32, 1); /* Performance Bar */ -$perf-bar-text: #999; $perf-bar-production: #222; $perf-bar-staging: #291430; $perf-bar-development: #4c1210; $perf-bar-bucket-bg: #111; -$perf-bar-bucket-color: #ccc; $perf-bar-bucket-box-shadow-from: rgba($white-light, 0.2); $perf-bar-bucket-box-shadow-to: rgba($black, 0.25); diff --git a/app/assets/stylesheets/framework/zen.scss b/app/assets/stylesheets/framework/zen.scss index f2d296fb875..a4fbd9c073f 100644 --- a/app/assets/stylesheets/framework/zen.scss +++ b/app/assets/stylesheets/framework/zen.scss @@ -35,7 +35,7 @@ .zen-control { padding: 0; - color: $zen-control-color; + color: $gl-gray-700; background: none; border: 0; } diff --git a/app/assets/stylesheets/notify.scss b/app/assets/stylesheets/notify.scss index a81e5eb5ebf..f24c80bd81c 100644 --- a/app/assets/stylesheets/notify.scss +++ b/app/assets/stylesheets/notify.scss @@ -7,12 +7,12 @@ img { p.details { font-style: italic; - color: $notify-details; + color: $gl-gray-500; } .footer > p { font-size: small; - color: $notify-footer; + color: $gl-gray-500; } pre.commit-message { diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 5ff4e487d04..45df8391f9a 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -7,6 +7,8 @@ $ide-context-header-padding: 10px; $ide-project-avatar-end: $ide-context-header-padding + 48px; $ide-tree-padding: $gl-padding; $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding; +$ide-commit-row-height: 32px; +$ide-commit-header-height: 48px; .project-refs-form, .project-refs-target-form { @@ -567,24 +569,11 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding; } .multi-file-commit-panel-header { - display: flex; - align-items: center; - margin-bottom: 0; + height: $ide-commit-header-height; border-bottom: 1px solid $white-dark; padding: 12px 0; } -.multi-file-commit-panel-header-title { - display: flex; - flex: 1; - align-items: center; - - svg { - margin-right: $gl-btn-padding; - color: $theme-gray-700; - } -} - .multi-file-commit-panel-collapse-btn { border-left: 1px solid $white-dark; margin-left: auto; @@ -594,8 +583,6 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding; flex: 1; overflow: auto; padding: $grid-size 0; - margin-left: -$grid-size; - margin-right: -$grid-size; min-height: 60px; &.form-text.text-muted { @@ -660,6 +647,8 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding; .multi-file-commit-list-path { cursor: pointer; + height: $ide-commit-row-height; + padding-right: 0; &.is-active { background-color: $white-normal; @@ -668,6 +657,12 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding; &:hover, &:focus { outline: 0; + + .multi-file-discard-btn { + > .btn { + display: flex; + } + } } svg { @@ -679,6 +674,7 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding; .multi-file-commit-list-file-path { @include str-truncated(calc(100% - 30px)); + user-select: none; &:active { text-decoration: none; @@ -686,9 +682,11 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding; } .multi-file-discard-btn { - top: 4px; - right: 8px; - bottom: 4px; + > .btn { + display: none; + width: $ide-commit-row-height; + height: $ide-commit-row-height; + } svg { top: 0; @@ -807,10 +805,9 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding; } .ide-staged-action-btn { - width: 22px; - margin-left: -1px; - border-top-left-radius: 0; - border-bottom-left-radius: 0; + width: $ide-commit-row-height; + height: $ide-commit-row-height; + color: inherit; > svg { top: 0; @@ -1442,3 +1439,29 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding; top: 50%; transform: translateY(-50%); } + +.ide-file-templates { + padding: $grid-size $gl-padding; + background-color: $gray-light; + border-bottom: 1px solid $white-dark; + + .dropdown { + min-width: 180px; + } + + .dropdown-content { + max-height: 222px; + } +} + +.ide-commit-editor-header { + height: 65px; + padding: 8px 16px; + background-color: $theme-gray-50; + box-shadow: inset 0 -1px $white-dark; +} + +.ide-commit-list-changed-icon { + width: $ide-commit-row-height; + height: $ide-commit-row-height; +} diff --git a/app/assets/stylesheets/pages/branches.scss b/app/assets/stylesheets/pages/branches.scss index 49fe50977f5..38fec3f0aa8 100644 --- a/app/assets/stylesheets/pages/branches.scss +++ b/app/assets/stylesheets/pages/branches.scss @@ -23,7 +23,7 @@ .bar { position: absolute; height: 4px; - background-color: $divergence-graph-bar-bg; + background-color: $gl-gray-200; } .bar-behind { @@ -61,7 +61,7 @@ height: 18px; margin: 5px 0 0; float: left; - background-color: $divergence-graph-separator-bg; + background-color: $gl-gray-200; } } diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index d673b59e1c0..987dcd32e3a 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -31,7 +31,7 @@ .file-mode-changed { padding: 10px; - color: $file-mode-changed; + color: $gl-gray-500; } .suppressed-container { @@ -72,6 +72,7 @@ .line_holder td { line-height: $code-line-height; font-size: $code-font-size; + vertical-align: top; &.noteable_line { position: relative; @@ -244,7 +245,7 @@ .swipe-wrap { overflow: hidden; - border-left: 1px solid $diff-swipe-border; + border-left: 1px solid $gl-gray-400; position: absolute; display: block; top: 13px; diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 196f6ae6d8c..79984c1a546 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -153,7 +153,7 @@ .x-axis path, .y-axis path { - stroke: $stat-graph-axis-fill; + stroke: $gl-gray-350; } .label-x-axis-line, @@ -163,7 +163,7 @@ .y-axis { line { - stroke: $stat-graph-axis-fill; + stroke: $gl-gray-350; stroke-width: 1; } } diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index da0c9b44498..a91d44805ee 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -87,7 +87,7 @@ border: 0; background: $gray-light; border-radius: 0; - color: $events-pre-color; + color: $gl-gray-500; overflow: hidden; } @@ -104,7 +104,7 @@ } .event-note-icon { - color: $events-pre-color; + color: $gl-gray-500; float: left; font-size: $gl-font-size; line-height: 16px; diff --git a/app/assets/stylesheets/pages/graph.scss b/app/assets/stylesheets/pages/graph.scss index 22fce893fd7..4fb1a956fab 100644 --- a/app/assets/stylesheets/pages/graph.scss +++ b/app/assets/stylesheets/pages/graph.scss @@ -20,7 +20,7 @@ .graphs { .graph-author-email { float: right; - color: $graph-author-email-color; + color: $gl-gray-500; } .graph-additions { @@ -58,7 +58,7 @@ .y-axis-label { line { - stroke: $stat-graph-axis-fill; + stroke: $gl-gray-350; } text { diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index 60b4d39bb1a..394c99268be 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -3,7 +3,6 @@ } .dashboard .side .card .card-header .input-group { - .form-control { height: 42px; } @@ -30,14 +29,15 @@ } } +.group-nav-container .group-search, .group-nav-container .nav-controls { display: flex; align-items: flex-start; - padding: $gl-padding-top 0; - border-bottom: 1px solid $border-color; + padding: $gl-padding-top 0 0; .group-filter-form { - flex: 1; + flex: 1 1 auto; + margin-right: $gl-padding-8; } .dropdown-menu-right { @@ -136,6 +136,10 @@ flex: 1; } + .dropdown-toggle { + width: auto; + } + .dropdown-menu { width: 100%; max-width: inherit; @@ -145,38 +149,14 @@ } } -.groups-empty-state { - padding: 50px 100px; - overflow: hidden; - - @include media-breakpoint-down(sm) { - padding: 50px 0; - } - - svg { - float: right; - - @include media-breakpoint-down(sm) { - float: none; - display: block; - width: 250px; - position: relative; - left: 50%; - margin-left: -125px; - } - } - - .text-content { - float: left; - width: 460px; - margin-top: 120px; +.group-nav-container .group-search { + padding: $gl-padding 0; + border-bottom: 1px solid $border-color; +} - @include media-breakpoint-down(sm) { - float: none; - margin-top: 60px; - width: auto; - text-align: center; - } +.groups-listing { + .group-list-tree .group-row:first-child { + border-top: 0; } } @@ -278,12 +258,12 @@ } &::after { - content: ""; + content: ''; position: absolute; height: 100%; width: 100%; background-color: transparent; - border: 2px outset $kdb-border; + border: 2px outset $gl-gray-200; border-radius: 50%; animation: spin-avatar 3s infinite linear; } @@ -346,7 +326,7 @@ position: relative; &::before { - content: ""; + content: ''; display: block; width: 10px; height: 0; diff --git a/app/assets/stylesheets/pages/help.scss b/app/assets/stylesheets/pages/help.scss index 0350fe5752e..2c23f31c240 100644 --- a/app/assets/stylesheets/pages/help.scss +++ b/app/assets/stylesheets/pages/help.scss @@ -1,6 +1,6 @@ .shortcut-mappings { font-size: 12px; - color: $help-shortcut-mapping-color; + color: $gl-gray-700; tbody:first-child tr:first-child { padding-top: 0; @@ -22,7 +22,7 @@ .shortcut { padding-right: 10px; - color: $help-shortcut-color; + color: $gl-gray-400; text-align: right; white-space: nowrap; } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 9ac47a771a5..cb29b5d4313 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -701,6 +701,10 @@ align-self: center; overflow: hidden; text-overflow: ellipsis; + + .user-status-emoji { + margin: 0 $gl-padding-8 0 $gl-padding-4; + } } .js-issuable-selector-wrap { diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 7b8cad254c7..9d46c2cf4fa 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -910,7 +910,7 @@ opacity: .65; &:hover { - color: $file-mode-changed; + color: $gl-gray-500; text-decoration: none; } } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index b1e33196049..c9e0899425f 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -94,8 +94,8 @@ ul.notes { opacity: 0.5; .dummy-avatar { - background-color: $kdb-border; - border: 1px solid darken($kdb-border, 25%); + background-color: $gl-gray-200; + border: 1px solid darken($gl-gray-200, 25%); } .note-headline-light, diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 9b7051924e6..7c42dcad959 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -371,7 +371,7 @@ .save-project-loader { margin-top: 50px; margin-bottom: 50px; - color: $save-project-loader-color; + color: $gl-gray-700; } .transfer-project .select2-container { @@ -447,7 +447,7 @@ > li + li::before { padding: 0 3px; - color: $project-breadcrumb-color; + color: $gl-gray-400; } a { diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 5a594920e44..dbf8692d69b 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -249,7 +249,7 @@ } .loading-metrics .metrics-load-spinner { - color: $loading-color; + color: $gl-gray-700; } .metrics-list { diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss index 5d3b7b21ce4..3fc37e20c36 100644 --- a/app/assets/stylesheets/pages/todos.scss +++ b/app/assets/stylesheets/pages/todos.scss @@ -143,7 +143,7 @@ border: 0; background: $gray-light; border-radius: 0; - color: $todo-body-pre-color; + color: $gl-gray-500; margin: 0 20px; overflow: hidden; } @@ -205,7 +205,7 @@ .todo-body { margin: 0; - border-left: 2px solid $todo-body-border; + border-left: 2px solid $gl-gray-100; padding-left: 10px; } } diff --git a/app/assets/stylesheets/pages/ui_dev_kit.scss b/app/assets/stylesheets/pages/ui_dev_kit.scss index 48ac5b21db8..84c617c7ec0 100644 --- a/app/assets/stylesheets/pages/ui_dev_kit.scss +++ b/app/assets/stylesheets/pages/ui_dev_kit.scss @@ -6,7 +6,7 @@ .example { padding: 15px; - border: 1px dashed $ui-dev-kit-example-border; + border: 1px dashed $gl-gray-100; margin-bottom: 15px; &::before { diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss index 57d43beaf21..2e2ab8532d2 100644 --- a/app/assets/stylesheets/performance_bar.scss +++ b/app/assets/stylesheets/performance_bar.scss @@ -11,10 +11,10 @@ height: $performance-bar-height; background: $black; line-height: $performance-bar-height; - color: $perf-bar-text; + color: $gl-gray-400; select { - color: $perf-bar-text; + color: $gl-gray-400; width: 200px; } @@ -53,7 +53,7 @@ padding: 4px 6px; font-family: Consolas, 'Liberation Mono', Courier, monospace; line-height: 1; - color: $perf-bar-bucket-color; + color: $gl-gray-200; border-radius: 3px; box-shadow: 0 1px 0 $perf-bar-bucket-box-shadow-from, inset 0 1px 2px $perf-bar-bucket-box-shadow-to; diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 9723e400574..869213d61f1 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -9,11 +9,18 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController .new(@application_setting, current_user, application_setting_params) .execute - if successful - redirect_to admin_application_settings_path, - notice: 'Application settings saved successfully' - else - render :show + if recheck_user_consent? + session[:ask_for_usage_stats_consent] = current_user.requires_usage_stats_consent? + end + + respond_to do |format| + if successful + format.json { head :ok } + format.html { redirect_to admin_application_settings_path, notice: 'Application settings saved successfully' } + else + format.json { head :bad_request } + format.html { render :show } + end end end @@ -76,6 +83,13 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController ) end + def recheck_user_consent? + return false unless session[:ask_for_usage_stats_consent] + return false unless params[:application_setting] + + params[:application_setting].key?(:usage_ping_enabled) || params[:application_setting].key?(:version_check_enabled) + end + def visible_application_setting_attributes ApplicationSettingsHelper.visible_attributes + [ :domain_blacklist_file, diff --git a/app/controllers/admin/logs_controller.rb b/app/controllers/admin/logs_controller.rb index 12a27cede75..7248edb64e1 100644 --- a/app/controllers/admin/logs_controller.rb +++ b/app/controllers/admin/logs_controller.rb @@ -12,7 +12,8 @@ class Admin::LogsController < Admin::ApplicationController Gitlab::GitLogger, Gitlab::EnvironmentLogger, Gitlab::SidekiqLogger, - Gitlab::RepositoryCheckLogger + Gitlab::RepositoryCheckLogger, + Gitlab::ProjectServiceLogger ] end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7cd68d6b92a..7e2b2cf3ad3 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -22,6 +22,7 @@ class ApplicationController < ActionController::Base before_action :add_gon_variables, unless: [:peek_request?, :json_request?] before_action :configure_permitted_parameters, if: :devise_controller? before_action :require_email, unless: :devise_controller? + before_action :set_usage_stats_consent_flag around_action :set_locale @@ -434,4 +435,29 @@ class ApplicationController < ActionController::Base !(peek_request? || devise_controller?) end + + def set_usage_stats_consent_flag + return unless current_user + return if sessionless_user? + return if session.has_key?(:ask_for_usage_stats_consent) + + session[:ask_for_usage_stats_consent] = current_user.requires_usage_stats_consent? + + if session[:ask_for_usage_stats_consent] + disable_usage_stats + end + end + + def disable_usage_stats + application_setting_params = { + usage_ping_enabled: false, + version_check_enabled: false, + skip_usage_stats_user: true + } + settings = Gitlab::CurrentSettings.current_application_settings + + ApplicationSettings::UpdateService + .new(settings, current_user, application_setting_params) + .execute + end end diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 37e03d70b6f..7b6e5bcb5f1 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -95,6 +95,7 @@ module IssuableActions .includes(:noteable) .fresh + notes = ResourceEvents::MergeIntoNotesService.new(issuable, current_user).execute(notes) notes = prepare_notes_for_rendering(notes) notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) } diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index 5127db3f5fb..b63f2eb85f0 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -18,6 +18,7 @@ module NotesActions notes = notes_finder.execute .inc_relations_for_view + notes = ResourceEvents::MergeIntoNotesService.new(noteable, current_user, last_fetched_at: current_fetched_at).execute(notes) notes = prepare_notes_for_rendering(notes) notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) } diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb index 3e0076ac935..059cf160fa2 100644 --- a/app/controllers/groups/labels_controller.rb +++ b/app/controllers/groups/labels_controller.rb @@ -2,7 +2,6 @@ class Groups::LabelsController < Groups::ApplicationController include ToggleSubscriptionAction before_action :label, only: [:edit, :update, :destroy] - before_action :available_labels, only: [:index] before_action :authorize_admin_labels!, only: [:new, :create, :edit, :update, :destroy] before_action :save_previous_label_path, only: [:edit] @@ -11,10 +10,13 @@ class Groups::LabelsController < Groups::ApplicationController def index respond_to do |format| format.html do - @labels = @available_labels.page(params[:page]) + @labels = @group.labels + .optionally_search(params[:search]) + .order_by(sort) + .page(params[:page]) end format.json do - render json: LabelSerializer.new.represent_appearance(@available_labels) + render json: LabelSerializer.new.represent_appearance(available_labels) end end end @@ -116,4 +118,8 @@ class Groups::LabelsController < Groups::ApplicationController include_descendant_groups: params[:include_descendant_groups], search: params[:search]).execute end + + def sort + @sort ||= params[:sort] || 'name_asc' + end end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index e57b9ff23a7..1f48c3417d0 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -17,7 +17,7 @@ class GroupsController < Groups::ApplicationController before_action :group_projects, only: [:projects, :activity, :issues, :merge_requests] before_action :event_filter, only: [:activity] - before_action :user_actions, only: [:show, :subgroups] + before_action :user_actions, only: [:show] skip_cross_project_access_check :index, :new, :create, :edit, :update, :destroy, :projects @@ -53,11 +53,7 @@ class GroupsController < Groups::ApplicationController def show respond_to do |format| - format.html do - @has_children = GroupDescendantsFinder.new(current_user: current_user, - parent_group: @group, - params: params).has_children? - end + format.html format.atom do load_events diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 6f50cbb4a36..5671663f81e 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -101,6 +101,7 @@ class ProfilesController < Profiles::ApplicationController :organization, :preferred_language, :private_profile, + :include_private_contributions, status: [:emoji, :message] ) end diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index 8a2bce6e7b5..69332ee2a0e 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -163,7 +163,12 @@ class Projects::LabelsController < Projects::ApplicationController LabelsFinder.new(current_user, project_id: @project.id, include_ancestor_groups: params[:include_ancestor_groups], - search: params[:search]).execute + search: params[:search], + sort: sort).execute + end + + def sort + @sort ||= params[:sort] || 'name_asc' end def authorize_admin_labels! diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 48e02581d54..34de554212f 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -21,6 +21,8 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic def render_diffs @environment = @merge_request.environments_for(current_user).last + @diffs.write_cache + render json: DiffsSerializer.new(current_user: current_user).represent(@diffs, additional_attributes) end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index d31b58972ca..75a85fafa3f 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -330,6 +330,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @source_project = @merge_request.source_project @target_project = @merge_request.target_project @target_branches = @merge_request.target_project.repository.branch_names + @noteable = @merge_request end def finder_type diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb index 32c0fc6d14a..ef0433795f4 100644 --- a/app/controllers/projects/registry/repositories_controller.rb +++ b/app/controllers/projects/registry/repositories_controller.rb @@ -18,14 +18,10 @@ module Projects end def destroy - if image.destroy - respond_to do |format| - format.json { head :no_content } - end - else - respond_to do |format| - format.json { head :bad_request } - end + DeleteContainerRepositoryWorker.perform_async(current_user.id, image.id) + + respond_to do |format| + format.json { head :no_content } end end @@ -41,10 +37,10 @@ module Projects # Needed to maintain a backwards compatibility. # def ensure_root_container_repository! - ContainerRegistry::Path.new(@project.full_path).tap do |path| + ::ContainerRegistry::Path.new(@project.full_path).tap do |path| break if path.has_repository? - ContainerRepository.build_from_path(path).tap do |repository| + ::ContainerRepository.build_from_path(path).tap do |repository| repository.save! if repository.has_tags? end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 0eaf9f94e37..98076791ab9 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -191,10 +191,8 @@ class ProjectsController < Projects::ApplicationController end def download_export - if export_project_object_storage? - send_upload(@project.import_export_upload.export_file) - elsif export_project_path - send_file export_project_path, disposition: 'attachment' + if @project.export_file_exists? + send_upload(@project.export_file) else redirect_to( edit_project_path(@project, anchor: 'js-export-project'), @@ -425,12 +423,4 @@ class ProjectsController < Projects::ApplicationController def whitelist_query_limiting Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42440') end - - def export_project_path - @export_project_path ||= @project.export_project_path - end - - def export_project_object_storage? - @project.export_project_object_exists? - end end diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb index 051ea108e06..2300b7fd114 100644 --- a/app/finders/group_descendants_finder.rb +++ b/app/finders/group_descendants_finder.rb @@ -134,7 +134,7 @@ class GroupDescendantsFinder end def direct_child_projects - GroupProjectsFinder.new(group: parent_group, current_user: current_user, params: params) + GroupProjectsFinder.new(group: parent_group, current_user: current_user, params: params, options: { only_owned: true }) .execute end diff --git a/app/finders/labels_finder.rb b/app/finders/labels_finder.rb index 1d05bf28438..8418577dab2 100644 --- a/app/finders/labels_finder.rb +++ b/app/finders/labels_finder.rb @@ -54,7 +54,11 @@ class LabelsFinder < UnionFinder end def sort(items) - items.reorder(title: :asc) + if params[:sort] + items.order_by(params[:sort]) + else + items.reorder(title: :asc) + end end def with_title(items) diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index cac6643eff3..0398ccce93b 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -49,6 +49,7 @@ class ProjectsFinder < UnionFinder collection = by_search(collection) collection = by_archived(collection) collection = by_custom_attributes(collection) + collection = by_deleted_status(collection) sort(collection) end @@ -131,6 +132,10 @@ class ProjectsFinder < UnionFinder params[:search].present? ? items.search(params[:search]) : items end + def by_deleted_status(items) + params[:without_deleted].present? ? items.without_deleted : items + end + def sort(items) params[:sort].present? ? items.sort_by_attribute(params[:sort]) : items.order_id_desc end diff --git a/app/finders/user_recent_events_finder.rb b/app/finders/user_recent_events_finder.rb index 876f086a3ef..b874f6959c9 100644 --- a/app/finders/user_recent_events_finder.rb +++ b/app/finders/user_recent_events_finder.rb @@ -48,20 +48,6 @@ class UserRecentEventsFinder end def projects - # Compile a list of projects `current_user` interacted with - # and `target_user` is allowed to see. - - authorized = target_user - .project_interactions - .joins(:project_authorizations) - .where(project_authorizations: { user: current_user }) - .select(:id) - - visible = target_user - .project_interactions - .where(visibility_level: Gitlab::VisibilityLevel.levels_for_user(current_user)) - .select(:id) - - Gitlab::SQL::Union.new([authorized, visible]).to_sql + target_user.project_interactions.to_sql end end diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb index 5d27d30eaa3..a4f19480539 100644 --- a/app/helpers/accounts_helper.rb +++ b/app/helpers/accounts_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module AccountsHelper def incoming_email_token_enabled? current_user.incoming_email_token && Gitlab::IncomingEmail.supports_issue_creation? diff --git a/app/helpers/active_sessions_helper.rb b/app/helpers/active_sessions_helper.rb index 97b6dac67c5..84aa1160f12 100644 --- a/app/helpers/active_sessions_helper.rb +++ b/app/helpers/active_sessions_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveSessionsHelper # Maps a device type as defined in `ActiveSession` to an svg icon name and # outputs the icon html. diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index f48db024e3f..ed13c5cfdd6 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module AppearancesHelper def brand_title current_appearance&.title.presence || 'GitLab Community Edition' diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 0190aa90763..bb7ae03313c 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'digest/md5' require 'uri' @@ -106,11 +108,11 @@ module ApplicationHelper # # Returns an HTML-safe String def time_ago_with_tooltip(time, placement: 'top', html_class: '', short_format: false) - css_classes = short_format ? 'js-short-timeago' : 'js-timeago' - css_classes << " #{html_class}" unless html_class.blank? + css_classes = [short_format ? 'js-short-timeago' : 'js-timeago'] + css_classes << html_class unless html_class.blank? element = content_tag :time, l(time, format: "%b %d, %Y"), - class: css_classes, + class: css_classes.join(' '), title: l(time.to_time.in_time_zone, format: :timeago_tooltip), datetime: time.to_time.getutc.iso8601, data: { diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 684c84c3006..3ebf0162cb6 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ApplicationSettingsHelper extend self @@ -73,12 +75,12 @@ module ApplicationSettingsHelper def oauth_providers_checkboxes button_based_providers.map do |source| disabled = Gitlab::CurrentSettings.disabled_oauth_sign_in_sources.include?(source.to_s) - css_class = 'btn' - css_class << ' active' unless disabled + css_class = ['btn'] + css_class << 'active' unless disabled checkbox_name = 'application_setting[enabled_oauth_sign_in_sources][]' name = Gitlab::Auth::OAuth::Provider.label_for(source) - label_tag(checkbox_name, class: css_class) do + label_tag(checkbox_name, class: css_class.join(' ')) do check_box_tag(checkbox_name, source, !disabled, autocomplete: 'off', id: name.tr(' ', '_')) + name @@ -220,6 +222,7 @@ module ApplicationSettingsHelper :recaptcha_enabled, :recaptcha_private_key, :recaptcha_site_key, + :receive_max_input_size, :repository_checks_enabled, :repository_storages, :require_two_factor_authentication, diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index 18f0979fc86..c17a54a6dff 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module AuthHelper PROVIDERS_WITH_ICONS = %w(twitter github gitlab bitbucket google_oauth2 facebook azure_oauth2 authentiq).freeze LDAP_PROVIDER = /\Aldap/ diff --git a/app/helpers/auto_devops_helper.rb b/app/helpers/auto_devops_helper.rb index 7b076728685..62fc6fb279f 100644 --- a/app/helpers/auto_devops_helper.rb +++ b/app/helpers/auto_devops_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module AutoDevopsHelper def show_auto_devops_callout?(project) Feature.get(:auto_devops_banner_disabled).off? && diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index 494f785e305..321811a3ca3 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module AvatarsHelper def project_icon(project_id, options = {}) source_icon(Project, project_id, options) @@ -125,9 +127,9 @@ module AvatarsHelper def source_identicon(source, options = {}) bg_key = (source.id % 7) + 1 - options[:class] ||= '' - options[:class] << ' identicon' - options[:class] << " bg#{bg_key}" + + options[:class] = + [*options[:class], "identicon bg#{bg_key}"].join(' ') content_tag(:div, class: options[:class].strip) do source.name[0, 1].upcase diff --git a/app/helpers/award_emoji_helper.rb b/app/helpers/award_emoji_helper.rb index 86b19368cfd..b97a95629f7 100644 --- a/app/helpers/award_emoji_helper.rb +++ b/app/helpers/award_emoji_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module AwardEmojiHelper def toggle_award_url(awardable) return url_for([:toggle_award_emoji, awardable]) unless @project || awardable.is_a?(Note) diff --git a/app/helpers/blame_helper.rb b/app/helpers/blame_helper.rb index 089d9e3e387..82c74e2416d 100644 --- a/app/helpers/blame_helper.rb +++ b/app/helpers/blame_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module BlameHelper def age_map_duration(blame_groups, project) now = Time.zone.now diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 96f7415ae98..9cbd5b5f785 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module BlobHelper def highlight(blob_name, blob_content, repository: nil, plain: false) plain ||= blob_content.length > Blob::MAXIMUM_TEXT_HIGHLIGHT_SIZE diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index af878bcf9a0..e3b74f443f7 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module BoardsHelper def board @board ||= @board || @boards.first diff --git a/app/helpers/branches_helper.rb b/app/helpers/branches_helper.rb index 07b1fc3d7cf..eadf48205fc 100644 --- a/app/helpers/branches_helper.rb +++ b/app/helpers/branches_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module BranchesHelper def project_branches options_for_select(@project.repository.branch_names, @project.default_branch) diff --git a/app/helpers/breadcrumbs_helper.rb b/app/helpers/breadcrumbs_helper.rb index e88fe6bcd7e..b067376cea0 100644 --- a/app/helpers/breadcrumbs_helper.rb +++ b/app/helpers/breadcrumbs_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module BreadcrumbsHelper def add_to_breadcrumbs(text, link) @breadcrumbs_extra_links ||= [] diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb index 0a15c29cfb5..289cb44f1e8 100644 --- a/app/helpers/broadcast_messages_helper.rb +++ b/app/helpers/broadcast_messages_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module BroadcastMessagesHelper def broadcast_message(message) return unless message.present? @@ -8,18 +10,17 @@ module BroadcastMessagesHelper end def broadcast_message_style(broadcast_message) - style = '' + style = [] if broadcast_message.color.present? style << "background-color: #{broadcast_message.color}" - style << '; ' if broadcast_message.font.present? end if broadcast_message.font.present? style << "color: #{broadcast_message.font}" end - style + style.join('; ') end def broadcast_message_status(broadcast_message) diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb index 4ec63fdaffc..3c8caec3fe5 100644 --- a/app/helpers/builds_helper.rb +++ b/app/helpers/builds_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module BuildsHelper def build_summary(build, skip: false) if build.has_trace? @@ -12,10 +14,10 @@ module BuildsHelper end def sidebar_build_class(build, current_build) - build_class = '' - build_class += ' active' if build.id === current_build.id - build_class += ' retried' if build.retried? - build_class + build_class = [] + build_class << 'active' if build.id === current_build.id + build_class << 'retried' if build.retried? + build_class.join(' ') end def javascript_build_options diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index 2b3fe57767c..7f071d55a6b 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ButtonHelper # Output a "Copy to Clipboard" button # diff --git a/app/helpers/calendar_helper.rb b/app/helpers/calendar_helper.rb index c54b91b0ce5..ad4116fc3da 100644 --- a/app/helpers/calendar_helper.rb +++ b/app/helpers/calendar_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module CalendarHelper def calendar_url_options { format: :ics, diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index 330959e536d..f8d36dce45d 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + ## # DEPRECATED # diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index 73049c74d80..a67c91b21d7 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ClustersHelper def has_multiple_clusters?(project) false diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 7a942c44ac4..d52cfd6e37a 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module CommitsHelper # Returns a link to the commit author. If the author has a matching user and # is a member of the current @project it will link to the team member page. diff --git a/app/helpers/compare_helper.rb b/app/helpers/compare_helper.rb index 2df5b5d1695..9ece8b0bc5b 100644 --- a/app/helpers/compare_helper.rb +++ b/app/helpers/compare_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module CompareHelper def create_mr_button?(from = params[:from], to = params[:to], project = @project) from.present? && diff --git a/app/helpers/components_helper.rb b/app/helpers/components_helper.rb index 8893209b314..d0ef86851ad 100644 --- a/app/helpers/components_helper.rb +++ b/app/helpers/components_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ComponentsHelper def gitlab_workhorse_version if request.headers['Gitlab-Workhorse'].present? diff --git a/app/helpers/conversational_development_index_helper.rb b/app/helpers/conversational_development_index_helper.rb index 1ff54415811..37e5bb325fb 100644 --- a/app/helpers/conversational_development_index_helper.rb +++ b/app/helpers/conversational_development_index_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ConversationalDevelopmentIndexHelper def score_level(score) if score < 33.33 diff --git a/app/helpers/count_helper.rb b/app/helpers/count_helper.rb index 5cd98f40f78..e16223a82c9 100644 --- a/app/helpers/count_helper.rb +++ b/app/helpers/count_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module CountHelper def approximate_count_with_delimiters(count_data, model) count = count_data[model] diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb index 19aa55a8d49..463f4145bdd 100644 --- a/app/helpers/dashboard_helper.rb +++ b/app/helpers/dashboard_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DashboardHelper def assigned_issues_dashboard_path issues_dashboard_path(assignee_id: current_user.id) diff --git a/app/helpers/defer_script_tag_helper.rb b/app/helpers/defer_script_tag_helper.rb index e1567556e5e..d91c6d52683 100644 --- a/app/helpers/defer_script_tag_helper.rb +++ b/app/helpers/defer_script_tag_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DeferScriptTagHelper # Override the default ActionView `javascript_include_tag` helper to support page specific deferred loading def javascript_include_tag(*sources) diff --git a/app/helpers/deploy_tokens_helper.rb b/app/helpers/deploy_tokens_helper.rb index bd921322476..80a5bb44c69 100644 --- a/app/helpers/deploy_tokens_helper.rb +++ b/app/helpers/deploy_tokens_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DeployTokensHelper def expand_deploy_tokens_section?(deploy_token) deploy_token.persisted? || diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index 1bb82fd8150..7684734c014 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DiffHelper def mark_inline_diffs(old_line, new_line) old_diffs, new_diffs = Gitlab::Diff::InlineDiff.new(old_line, new_line).inline_diffs @@ -39,7 +41,8 @@ module DiffHelper line_num_class = %w[diff-line-num unfold js-unfold] line_num_class << 'js-unfold-bottom' if bottom - html = '' + html = [] + if old_pos html << content_tag(:td, '...', class: [*line_num_class, 'old_line'], data: { linenumber: old_pos }) html << content_tag(:td, text, class: [*content_line_class, 'left-side']) if view == :parallel @@ -50,7 +53,7 @@ module DiffHelper html << content_tag(:td, text, class: [*content_line_class, ('right-side' if view == :parallel)]) end - html.html_safe + html.join.html_safe end def diff_line_content(line) @@ -215,9 +218,7 @@ module DiffHelper end def toggle_whitespace_link(url, options) - options[:class] ||= '' - options[:class] << ' btn btn-default' - + options[:class] = [*options[:class], 'btn btn-default'].join(' ') link_to "#{hide_whitespace? ? 'Show' : 'Hide'} whitespace changes", url, class: options[:class] end diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index 5a2360b4661..4b6c5b215e8 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DropdownsHelper def dropdown_tag(toggle_text, options: {}, &block) content_tag :div, class: "dropdown #{options[:wrapper_class] if options.key?(:wrapper_class)}" do @@ -10,7 +12,7 @@ module DropdownsHelper dropdown_output = dropdown_toggle(toggle_text, data_attr, options) dropdown_output << content_tag(:div, class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.key?(:dropdown_class)}") do - output = "" + output = [] if options.key?(:title) output << dropdown_title(options[:title]) @@ -31,8 +33,7 @@ module DropdownsHelper end output << dropdown_loading - - output.html_safe + output.join.html_safe end dropdown_output.html_safe @@ -50,7 +51,7 @@ module DropdownsHelper def dropdown_title(title, options: {}) content_tag :div, class: "dropdown-title" do - title_output = "" + title_output = [] if options.fetch(:back, false) title_output << content_tag(:button, class: "dropdown-title-button dropdown-menu-back", aria: { label: "Go back" }, type: "button") do @@ -66,7 +67,7 @@ module DropdownsHelper end end - title_output.html_safe + title_output.join.html_safe end end diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index c86a26ac30f..2d2e89a2a50 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module EmailsHelper include AppearancesHelper @@ -49,8 +51,8 @@ module EmailsHelper def reset_token_expire_message link_tag = link_to('request a new one', new_user_password_url(user_email: @user.email)) - msg = "This link is valid for #{password_reset_token_valid_time}. " - msg << "After it expires, you can #{link_tag}." + "This link is valid for #{password_reset_token_valid_time}. " \ + "After it expires, you can #{link_tag}." end def header_logo diff --git a/app/helpers/emoji_helper.rb b/app/helpers/emoji_helper.rb index 482f68f412b..51b7fd7f352 100644 --- a/app/helpers/emoji_helper.rb +++ b/app/helpers/emoji_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module EmojiHelper def emoji_icon(*args) raw Gitlab::Emoji.gl_emoji_tag(*args) diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb index 1e78a189c08..4b3ef2de701 100644 --- a/app/helpers/environment_helper.rb +++ b/app/helpers/environment_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module EnvironmentHelper def environment_for_build(project, build) return unless build.environment diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index c005ecbb56b..7b22bc8f98f 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module EnvironmentsHelper def environments_list_data { diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 269acf5b2e2..c94946a04e7 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module EventsHelper ICON_NAMES_BY_EVENT_TYPE = { 'pushed to' => 'commit', @@ -19,7 +21,7 @@ module EventsHelper name = self_added ? 'You' : author.name link_to name, user_path(author.username), title: name else - event.author_name + escape_once(event.author_name) end end diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb index f062a91a166..62be591ec47 100644 --- a/app/helpers/explore_helper.rb +++ b/app/helpers/explore_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ExploreHelper def filter_projects_path(options = {}) exist_opts = { diff --git a/app/helpers/external_wiki_helper.rb b/app/helpers/external_wiki_helper.rb index 8cf890b74a8..e36d63b2946 100644 --- a/app/helpers/external_wiki_helper.rb +++ b/app/helpers/external_wiki_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ExternalWikiHelper def get_project_wiki_path(project) external_wiki_service = project.external_wiki diff --git a/app/helpers/favicon_helper.rb b/app/helpers/favicon_helper.rb index 3a5342a8d9d..4a809731d97 100644 --- a/app/helpers/favicon_helper.rb +++ b/app/helpers/favicon_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module FaviconHelper def favicon_extension_whitelist FaviconUploader::EXTENSION_WHITELIST diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index 905e2002592..5705ee54cee 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module FormHelper def form_errors(model, type: 'form') return unless model.errors.any? diff --git a/app/helpers/git_helper.rb b/app/helpers/git_helper.rb index 8ab394384f3..5edc6dcf454 100644 --- a/app/helpers/git_helper.rb +++ b/app/helpers/git_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module GitHelper def strip_gpg_signature(text) text.gsub(/-----BEGIN PGP SIGNATURE-----(.*)-----END PGP SIGNATURE-----/m, "") diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index 61e12b0f31e..04cf43be452 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Shorter routing method for some project items module GitlabRoutingHelper extend ActiveSupport::Concern diff --git a/app/helpers/graph_helper.rb b/app/helpers/graph_helper.rb index 1022070ab6f..49b15cde009 100644 --- a/app/helpers/graph_helper.rb +++ b/app/helpers/graph_helper.rb @@ -1,12 +1,14 @@ +# frozen_string_literal: true + module GraphHelper def refs(repo, commit) - refs = commit.ref_names(repo).join(' ') + refs = [commit.ref_names(repo).join(' ')] # append note count notes_count = @graph.notes[commit.id] refs << "[#{pluralize(notes_count, 'note')}]" if notes_count > 0 - refs + refs.join end def parents_zip_spaces(parents, parent_spaces) diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 5b51d2f2425..f573fd399a5 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module GroupsHelper def group_nav_link_paths %w[groups#projects groups#edit badges#index ci_cd#show ldap_group_links#index hooks#index audit_events#index pipeline_quota#index] @@ -43,22 +45,22 @@ module GroupsHelper def group_title(group, name = nil, url = nil) @has_group_title = true - full_title = '' + full_title = [] group.ancestors.reverse.each_with_index do |parent, index| if index > 0 add_to_breadcrumb_dropdown(group_title_link(parent, hidable: false, show_avatar: true, for_dropdown: true), location: :before) else - full_title += breadcrumb_list_item group_title_link(parent, hidable: false) + full_title << breadcrumb_list_item(group_title_link(parent, hidable: false)) end end - full_title += render "layouts/nav/breadcrumbs/collapsed_dropdown", location: :before, title: _("Show parent subgroups") + full_title << render("layouts/nav/breadcrumbs/collapsed_dropdown", location: :before, title: _("Show parent subgroups")) - full_title += breadcrumb_list_item group_title_link(group) - full_title += ' · '.html_safe + link_to(simple_sanitize(name), url, class: 'group-path breadcrumb-item-text js-breadcrumb-item-text') if name + full_title << breadcrumb_list_item(group_title_link(group)) + full_title << ' · '.html_safe + link_to(simple_sanitize(name), url, class: 'group-path breadcrumb-item-text js-breadcrumb-item-text') if name - full_title.html_safe + full_title.join.html_safe end def projects_lfs_status(group) @@ -138,15 +140,8 @@ module GroupsHelper def group_title_link(group, hidable: false, show_avatar: false, for_dropdown: false) link_to(group_path(group), class: "group-path #{'breadcrumb-item-text' unless for_dropdown} js-breadcrumb-item-text #{'hidable' if hidable}") do - output = - if (group.try(:avatar_url) || show_avatar) && !Rails.env.test? - group_icon(group, class: "avatar-tile", width: 15, height: 15) - else - "" - end - - output << simple_sanitize(group.name) - output.html_safe + icon = group_icon(group, class: "avatar-tile", width: 15, height: 15) if (group.try(:avatar_url) || show_avatar) && !Rails.env.test? + [icon, simple_sanitize(group.name)].join.html_safe end end diff --git a/app/helpers/hooks_helper.rb b/app/helpers/hooks_helper.rb index 0a356ba55d2..c4b39939192 100644 --- a/app/helpers/hooks_helper.rb +++ b/app/helpers/hooks_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module HooksHelper def link_to_test_hook(hook, trigger) path = case hook diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index a5612372aa6..037004327b9 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'json' module IconsHelper @@ -47,9 +49,10 @@ module IconsHelper end end - css_classes = size ? "s#{size}" : "" - css_classes << " #{css_class}" unless css_class.blank? - content_tag(:svg, content_tag(:use, "", { "xlink:href" => "#{sprite_icon_path}##{icon_name}" } ), class: css_classes.empty? ? nil : css_classes) + css_classes = [] + css_classes << "s#{size}" if size + css_classes << "#{css_class}" unless css_class.blank? + content_tag(:svg, content_tag(:use, "", { "xlink:href" => "#{sprite_icon_path}##{icon_name}" } ), class: css_classes.empty? ? nil : css_classes.join(' ')) end def external_snippet_icon(name) @@ -70,10 +73,10 @@ module IconsHelper end def spinner(text = nil, visible = false) - css_class = 'loading' - css_class << ' hide' unless visible + css_class = ['loading'] + css_class << 'hide' unless visible - content_tag :div, class: css_class do + content_tag :div, class: css_class.join(' ') do icon('spinner spin') + text end end @@ -97,9 +100,10 @@ module IconsHelper 'globe' end - name << " fw" if fw + name = [name] + name << "fw" if fw - icon(name, options) + icon(name.join(' '), options) end def file_type_icon_class(type, mode, name) diff --git a/app/helpers/import_helper.rb b/app/helpers/import_helper.rb index c65f1565425..3d0eb3d0d51 100644 --- a/app/helpers/import_helper.rb +++ b/app/helpers/import_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ImportHelper include ::Gitlab::Utils::StrongMemoize diff --git a/app/helpers/instance_configuration_helper.rb b/app/helpers/instance_configuration_helper.rb index cee319f20bc..f695be32743 100644 --- a/app/helpers/instance_configuration_helper.rb +++ b/app/helpers/instance_configuration_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module InstanceConfigurationHelper def instance_configuration_cell_html(value, &block) return '-' unless value.to_s.presence diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index c84ed8091c3..8396cfe0ac8 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module IssuablesHelper include GitlabRoutingHelper @@ -167,26 +169,26 @@ module IssuablesHelper end def issuable_meta(issuable, project, text) - output = "" + output = [] output << "Opened #{time_ago_with_tooltip(issuable.created_at)} by ".html_safe + output << content_tag(:strong) do author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline", tooltip: true) author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-block d-sm-none") if status = user_status(issuable.author) - author_output << "  #{status}".html_safe + author_output << "#{status}".html_safe end author_output end - output << " ".html_safe output << content_tag(:span, (issuable_first_contribution_icon if issuable.first_contribution?), class: 'has-tooltip', title: _('1st contribution!')) output << content_tag(:span, (issuable.task_status if issuable.tasks?), id: "task_status", class: "d-none d-sm-none d-md-inline-block") output << content_tag(:span, (issuable.task_status_short if issuable.tasks?), id: "task_status_short", class: "d-md-none") - output.html_safe + output.join.html_safe end def issuable_todo(issuable) diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 5b27d1d9404..f7d448ea3a7 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -1,9 +1,11 @@ +# frozen_string_literal: true + module IssuesHelper def issue_css_classes(issue) - classes = "issue" - classes << " closed" if issue.closed? - classes << " today" if issue.today? - classes + classes = ["issue"] + classes << "closed" if issue.closed? + classes << "today" if issue.today? + classes.join(' ') end # Returns an OpenStruct object suitable for use by <tt>options_from_collection_for_select</tt> @@ -105,8 +107,8 @@ module IssuesHelper end def link_to_discussions_to_resolve(merge_request, single_discussion = nil) - link_text = merge_request.to_reference - link_text += " (discussion #{single_discussion.first_note.id})" if single_discussion + link_text = [merge_request.to_reference] + link_text << "(discussion #{single_discussion.first_note.id})" if single_discussion path = if single_discussion Gitlab::UrlBuilder.build(single_discussion.first_note) @@ -115,7 +117,7 @@ module IssuesHelper project_merge_request_path(project, merge_request) end - link_to link_text, path + link_to link_text.join(' '), path end def show_new_issue_link?(project) diff --git a/app/helpers/javascript_helper.rb b/app/helpers/javascript_helper.rb index cd4075b340d..7cb6da26236 100644 --- a/app/helpers/javascript_helper.rb +++ b/app/helpers/javascript_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JavascriptHelper def page_specific_javascript_tag(js) javascript_include_tag asset_path(js) diff --git a/app/helpers/kerberos_spnego_helper.rb b/app/helpers/kerberos_spnego_helper.rb index f5b0aa7549a..c0eb8f83f56 100644 --- a/app/helpers/kerberos_spnego_helper.rb +++ b/app/helpers/kerberos_spnego_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module KerberosSpnegoHelper def allow_basic_auth? true # different behavior in GitLab Enterprise Edition diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index c7df25cecef..6c51739ba1a 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module LabelsHelper extend self include ActionView::Helpers::TagHelper diff --git a/app/helpers/lazy_image_tag_helper.rb b/app/helpers/lazy_image_tag_helper.rb index 603b9438e35..ac987a04895 100644 --- a/app/helpers/lazy_image_tag_helper.rb +++ b/app/helpers/lazy_image_tag_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module LazyImageTagHelper def placeholder_image "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" @@ -11,9 +13,11 @@ module LazyImageTagHelper options[:data] ||= {} options[:data][:src] = path_to_image(source) - options[:class] ||= "" - options[:class] << " lazy" + # options[:class] can be either String or Array. + klass_opts = Array.wrap(options[:class]) + klass_opts << "lazy" + options[:class] = klass_opts.join(' ') source = placeholder_image end diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index 3adaa1366c0..f2cd676bb1b 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'nokogiri' module MarkupHelper diff --git a/app/helpers/mattermost_helper.rb b/app/helpers/mattermost_helper.rb index 27ff4051c8d..b211fe5076a 100644 --- a/app/helpers/mattermost_helper.rb +++ b/app/helpers/mattermost_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module MattermostHelper def mattermost_teams_options(teams) teams.map do |team| diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index a3129cac2b1..5a21403bc5e 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + module MembersHelper def remove_member_message(member, user: nil) user = current_user if defined?(current_user) + text = 'Are you sure you want to' - text = 'Are you sure you want to ' action = if member.request? if member.user == user @@ -16,13 +18,12 @@ module MembersHelper "remove #{member.user.name} from" end - text << action << " the #{member.source.human_name} #{member.real_source_type.humanize(capitalize: false)}?" + "#{text} #{action} the #{member.source.human_name} #{member.real_source_type.humanize(capitalize: false)}?" end def remove_member_title(member) - text = " from #{member.real_source_type.humanize(capitalize: false)}" - - text.prepend(member.request? ? 'Deny access request' : 'Remove user') + action = member.request? ? 'Deny access request' : 'Remove user' + "#{action} from #{member.real_source_type.humanize(capitalize: false)}" end def leave_confirmation_message(member_source) @@ -32,9 +33,6 @@ module MembersHelper def filter_group_project_member_path(options = {}) options = params.slice(:search, :sort).merge(options) - - path = request.path - path << "?#{options.to_param}" - path + "#{request.path}?#{options.to_param}" end end diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 097be8a0643..87af6fb08f0 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module MergeRequestsHelper def new_mr_path_from_push_event(event) target_project = event.project.default_merge_request_target @@ -19,10 +21,10 @@ module MergeRequestsHelper end def mr_css_classes(mr) - classes = "merge-request" - classes << " closed" if mr.closed? - classes << " merged" if mr.merged? - classes + classes = ["merge-request"] + classes << "closed" if mr.closed? + classes << "merged" if mr.merged? + classes.join(' ') end def ci_build_details_path(merge_request) diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index 95da8f00aff..999143002bb 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module MilestonesHelper include EntityDateHelper @@ -119,20 +121,18 @@ module MilestonesHelper title = date_type == :start ? "Start date" : "End date" if date - time_ago = time_ago_in_words(date) - time_ago.slice!("about ") - - time_ago << if date.past? - " ago" - else - " remaining" - end + time_ago = time_ago_in_words(date).sub("about ", "") + state = if date.past? + "ago" + else + "remaining" + end content = [ title, "<br />", date.to_s(:medium), - "(#{time_ago})" + "(#{time_ago} #{state})" ].join(" ") content.html_safe diff --git a/app/helpers/milestones_routing_helper.rb b/app/helpers/milestones_routing_helper.rb index a0b2616f224..a49b561533a 100644 --- a/app/helpers/milestones_routing_helper.rb +++ b/app/helpers/milestones_routing_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module MilestonesRoutingHelper def milestone_path(milestone, *args) if milestone.group_milestone? diff --git a/app/helpers/mirror_helper.rb b/app/helpers/mirror_helper.rb index 93ed22513ac..a4025730397 100644 --- a/app/helpers/mirror_helper.rb +++ b/app/helpers/mirror_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module MirrorHelper def mirrors_form_data_attributes { project_mirror_endpoint: project_mirror_path(@project) } diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb index 6535afb6425..e7537ca5733 100644 --- a/app/helpers/namespaces_helper.rb +++ b/app/helpers/namespaces_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module NamespacesHelper def namespace_id_from(params) params.dig(:project, :namespace_id) || params[:namespace_id] diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index a84a39235d8..761f42f2f0f 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module NavHelper def header_links @header_links ||= get_header_links diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 5404ead44f3..a80c8f273a8 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module NotesHelper def note_target_fields(note) if note.noteable @@ -108,7 +110,7 @@ module NotesHelper end def noteable_note_url(note) - Gitlab::UrlBuilder.build(note) + Gitlab::UrlBuilder.build(note) if note.id end def form_resources diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index a185f2916d4..5318ab4ddef 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module NotificationsHelper include IconsHelper diff --git a/app/helpers/numbers_helper.rb b/app/helpers/numbers_helper.rb index 45bd3606076..f609b6c0cec 100644 --- a/app/helpers/numbers_helper.rb +++ b/app/helpers/numbers_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module NumbersHelper def limited_counter_with_delimiter(resource, **options) limit = options.fetch(:limit, 1000).to_i diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb index 68d892393ef..b33c074d1af 100644 --- a/app/helpers/page_layout_helper.rb +++ b/app/helpers/page_layout_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module PageLayoutHelper def page_title(*titles) @page_title ||= [] @@ -65,14 +67,14 @@ module PageLayoutHelper end def page_card_meta_tags - tags = '' + tags = [] page_card_attributes.each_with_index do |pair, i| tags << tag(:meta, property: "twitter:label#{i + 1}", content: pair[0]) tags << tag(:meta, property: "twitter:data#{i + 1}", content: pair[1]) end - tags.html_safe + tags.join.html_safe end def header_title(title = nil, title_url = nil) @@ -115,16 +117,16 @@ module PageLayoutHelper end def container_class - css_class = "container-fluid" + css_class = ["container-fluid"] unless fluid_layout - css_class += " container-limited" + css_class << "container-limited" end if blank_container - css_class += " container-blank" + css_class << "container-blank" end - css_class + css_class.join(' ') end end diff --git a/app/helpers/pagination_helper.rb b/app/helpers/pagination_helper.rb index 83dd76a01dd..d05153c9d4b 100644 --- a/app/helpers/pagination_helper.rb +++ b/app/helpers/pagination_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module PaginationHelper def paginate_collection(collection, remote: nil) if collection.is_a?(Kaminari::PaginatableWithoutCount) diff --git a/app/helpers/performance_bar_helper.rb b/app/helpers/performance_bar_helper.rb index d24efe37f5f..7518cec160c 100644 --- a/app/helpers/performance_bar_helper.rb +++ b/app/helpers/performance_bar_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module PerformanceBarHelper # This is a hack since using `alias_method :performance_bar_enabled?, :peek_enabled?` # in WithPerformanceBar breaks tests (but works in the browser). diff --git a/app/helpers/pipeline_schedules_helper.rb b/app/helpers/pipeline_schedules_helper.rb index 4b9f6bd2caf..0e166106b32 100644 --- a/app/helpers/pipeline_schedules_helper.rb +++ b/app/helpers/pipeline_schedules_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module PipelineSchedulesHelper def timezone_data ActiveSupport::TimeZone.all.map do |timezone| diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index fb523cb865b..ff9842d4cd9 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Helper methods for per-User preferences module PreferencesHelper def layout_choices diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb index e7aa92e6e5c..55674e37a34 100644 --- a/app/helpers/profiles_helper.rb +++ b/app/helpers/profiles_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ProfilesHelper def attribute_provider_label(attribute) user_synced_attributes_metadata = current_user.user_synced_attributes_metadata diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 80b45176a62..89fee06ee77 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ProjectsHelper def link_to_project(project) link_to [project.namespace.becomes(Namespace), project], title: h(project.name) do @@ -50,7 +52,7 @@ module ProjectsHelper return "(deleted)" unless author - author_html = "" + author_html = [] # Build avatar image tag author_html << link_to_member_avatar(author, opts) if opts[:avatar] @@ -60,7 +62,7 @@ module ProjectsHelper author_html << capture(&block) if block - author_html = author_html.html_safe + author_html = author_html.join.html_safe if opts[:name] link_to(author_html, user_path(author), class: "author-link #{"#{opts[:extra_class]}" if opts[:extra_class]} #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}").html_safe @@ -80,15 +82,8 @@ module ProjectsHelper end project_link = link_to project_path(project) do - output = - if project.avatar_url && !Rails.env.test? - project_icon(project, alt: project.name, class: 'avatar-tile', width: 15, height: 15) - else - "" - end - - output << content_tag("span", simple_sanitize(project.name), class: "breadcrumb-item-text js-breadcrumb-item-text") - output.html_safe + icon = project_icon(project, alt: project.name, class: 'avatar-tile', width: 15, height: 15) if project.avatar_url && !Rails.env.test? + [icon, content_tag("span", simple_sanitize(project.name), class: "breadcrumb-item-text js-breadcrumb-item-text")].join.html_safe end namespace_link = breadcrumb_list_item(namespace_link) unless project.group @@ -203,6 +198,14 @@ module ProjectsHelper current_user.require_extra_setup_for_git_auth? end + def show_auto_devops_implicitly_enabled_banner?(project) + cookie_key = "hide_auto_devops_implicitly_enabled_banner_#{project.id}" + + project.has_auto_devops_implicitly_enabled? && + cookies[cookie_key.to_sym].blank? && + (project.owner == current_user || project.team.maintainer?(current_user)) + end + def link_to_set_password if current_user.require_password_creation_for_git? link_to s_('SetPasswordToCloneLink|set a password'), edit_profile_password_path diff --git a/app/helpers/repository_languages_helper.rb b/app/helpers/repository_languages_helper.rb index 9a842cf5ce0..c1505b52808 100644 --- a/app/helpers/repository_languages_helper.rb +++ b/app/helpers/repository_languages_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module RepositoryLanguagesHelper def repository_languages_bar(languages) return if languages.none? diff --git a/app/helpers/rss_helper.rb b/app/helpers/rss_helper.rb index 7d4fa83a67a..67c7d244f11 100644 --- a/app/helpers/rss_helper.rb +++ b/app/helpers/rss_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module RssHelper def rss_url_options { format: :atom, feed_token: current_user.try(:feed_token) } diff --git a/app/helpers/runners_helper.rb b/app/helpers/runners_helper.rb index 9fb42487a75..cb21f922401 100644 --- a/app/helpers/runners_helper.rb +++ b/app/helpers/runners_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module RunnersHelper def runner_status_icon(runner) status = runner.status diff --git a/app/helpers/safe_params_helper.rb b/app/helpers/safe_params_helper.rb index b568e8810cc..72bf1377b02 100644 --- a/app/helpers/safe_params_helper.rb +++ b/app/helpers/safe_params_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module SafeParamsHelper # Rails 5.0 requires to permit `params` if they're used in url helpers. # Use this helper when generating links with `params.merge(...)` diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 98074a4c0c5..c509cd592c4 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module SearchHelper def search_autocomplete_opts(term) return unless current_user diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb index 6cefcde558a..cf60696ef39 100644 --- a/app/helpers/selects_helper.rb +++ b/app/helpers/selects_helper.rb @@ -1,12 +1,14 @@ +# frozen_string_literal: true + module SelectsHelper def users_select_tag(id, opts = {}) - css_class = "ajax-users-select " - css_class << "multiselect " if opts[:multiple] - css_class << "skip_ldap " if opts[:skip_ldap] + css_class = ["ajax-users-select"] + css_class << "multiselect" if opts[:multiple] + css_class << "skip_ldap" if opts[:skip_ldap] css_class << (opts[:class] || '') value = opts[:selected] || '' html = { - class: css_class, + class: css_class.join(' '), data: users_select_data_attributes(opts) } @@ -24,20 +26,21 @@ module SelectsHelper end def groups_select_tag(id, opts = {}) - opts[:class] ||= '' - opts[:class] << ' ajax-groups-select' + classes = Array.wrap(opts[:class]) + classes << 'ajax-groups-select' + + opts[:class] = classes.join(' ') + select2_tag(id, opts) end def namespace_select_tag(id, opts = {}) - opts[:class] ||= '' - opts[:class] << ' ajax-namespace-select' + opts[:class] = [*opts[:class], 'ajax-namespace-select'].join(' ') select2_tag(id, opts) end def project_select_tag(id, opts = {}) - opts[:class] ||= '' - opts[:class] << ' ajax-project-select' + opts[:class] = [*opts[:class], 'ajax-project-select'].join(' ') unless opts.delete(:scope) == :all if @group @@ -57,7 +60,10 @@ module SelectsHelper end def select2_tag(id, opts = {}) - opts[:class] << ' multiselect' if opts[:multiple] + klass_opts = [opts[:class]] + klass_opts << 'multiselect' if opts[:multiple] + + opts[:class] = klass_opts.join(' ') value = opts[:selected] || '' hidden_field_tag(id, value, opts) diff --git a/app/helpers/sentry_helper.rb b/app/helpers/sentry_helper.rb index 3d255df66a0..d53eaef9952 100644 --- a/app/helpers/sentry_helper.rb +++ b/app/helpers/sentry_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module SentryHelper def sentry_enabled? Gitlab::Sentry.enabled? diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb index f872990122e..8b554e1aaa9 100644 --- a/app/helpers/services_helper.rb +++ b/app/helpers/services_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ServicesHelper def service_event_description(event) case event diff --git a/app/helpers/sidekiq_helper.rb b/app/helpers/sidekiq_helper.rb index 50aeb7f4b82..32bf3526571 100644 --- a/app/helpers/sidekiq_helper.rb +++ b/app/helpers/sidekiq_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module SidekiqHelper SIDEKIQ_PS_REGEXP = %r{\A (?<pid>\d+)\s+ diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb index a05640773ad..c7d31f3469d 100644 --- a/app/helpers/snippets_helper.rb +++ b/app/helpers/snippets_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module SnippetsHelper def reliable_snippet_path(snippet, opts = nil) if snippet.project_id? diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 36a311dfa8a..a6e65d30eda 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module SortingHelper def sort_options_hash { @@ -99,6 +101,17 @@ module SortingHelper } end + def label_sort_options_hash + { + sort_value_name => sort_title_name, + sort_value_name_desc => sort_title_name_desc, + sort_value_recently_created => sort_title_recently_created, + sort_value_oldest_created => sort_title_oldest_created, + sort_value_recently_updated => sort_title_recently_updated, + sort_value_oldest_updated => sort_title_oldest_updated + } + end + def sortable_item(item, path, sorted_by) link_to item, path, class: sorted_by == item ? 'is-active' : '' end diff --git a/app/helpers/storage_health_helper.rb b/app/helpers/storage_health_helper.rb index b76c1228220..182e8e6641b 100644 --- a/app/helpers/storage_health_helper.rb +++ b/app/helpers/storage_health_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module StorageHealthHelper def failing_storage_health_message(storage_health) storage_name = content_tag(:strong, h(storage_health.storage_name)) diff --git a/app/helpers/storage_helper.rb b/app/helpers/storage_helper.rb index e19c67a37ca..be8761db562 100644 --- a/app/helpers/storage_helper.rb +++ b/app/helpers/storage_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module StorageHelper def storage_counter(size_in_bytes) precision = size_in_bytes < 1.megabyte ? 0 : 1 diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb index ec2cf2b16c0..164c69ca50b 100644 --- a/app/helpers/submodule_helper.rb +++ b/app/helpers/submodule_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module SubmoduleHelper extend self diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index 5b4a141dbcf..ac4e8f54260 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module SystemNoteHelper ICON_NAMES_BY_ACTION = { 'commit' => 'commit', @@ -21,7 +23,8 @@ module SystemNoteHelper 'outdated' => 'pencil-square', 'duplicate' => 'issue-duplicate', 'locked' => 'lock', - 'unlocked' => 'lock-open' + 'unlocked' => 'lock-open', + 'due_date' => 'calendar' }.freeze def system_note_icon_name(note) diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb index ee701076a14..e310fda51d7 100644 --- a/app/helpers/tab_helper.rb +++ b/app/helpers/tab_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module TabHelper # Navigation link helper # @@ -47,9 +49,7 @@ module TabHelper # Add our custom class into the html_options, which may or may not exist # and which may or may not already have a :class key o = options.delete(:html_options) || {} - o[:class] ||= '' - o[:class] += ' ' + klass - o[:class].strip! + o[:class] = [*o[:class], klass].join(' ').strip if block_given? content_tag(:li, capture(&block), o) diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb index d000d6b1c0a..de0b92b6fd7 100644 --- a/app/helpers/tags_helper.rb +++ b/app/helpers/tags_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module TagsHelper def tag_path(tag) "/tags/#{tag}" @@ -14,12 +16,13 @@ module TagsHelper end def tag_list(project) - html = '' + html = [] + project.tag_list.each do |tag| html << link_to(tag, tag_path(tag)) end - html.html_safe + html.join.html_safe end def protected_tag?(project, tag) diff --git a/app/helpers/time_helper.rb b/app/helpers/time_helper.rb index 336385f6798..94044d7b85e 100644 --- a/app/helpers/time_helper.rb +++ b/app/helpers/time_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module TimeHelper def time_interval_in_words(interval_in_seconds) interval_in_seconds = interval_in_seconds.to_i diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index 7cd74358168..6bd78336ed3 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module TodosHelper def todos_pending_count @todos_pending_count ||= current_user.todos_pending_count @@ -94,9 +96,7 @@ module TodosHelper end end - path = request.path - path << "?#{options.to_param}" - path + "#{request.path}?#{options.to_param}" end def todo_actions_options @@ -152,10 +152,11 @@ module TodosHelper '' end - html = "· ".html_safe - html << content_tag(:span, class: css_class) do + content = content_tag(:span, class: css_class) do "Due #{is_due_today ? "today" : todo.target.due_date.to_s(:medium)}" end + + "· #{content}".html_safe end private diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index dc42caa70e5..80f61a371fd 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module TreeHelper FILE_LIMIT = 1_000 @@ -8,7 +10,7 @@ module TreeHelper def render_tree(tree) # Sort submodules and folders together by name ahead of files folders, files, submodules = tree.trees, tree.blobs, tree.submodules - tree = '' + tree = [] items = (folders + submodules).sort_by(&:name) + files if items.size > FILE_LIMIT @@ -18,7 +20,7 @@ module TreeHelper end tree << render(partial: 'projects/tree/tree_row', collection: items) if items.present? - tree.html_safe + tree.join.html_safe end # Return an image icon depending on the file type and mode diff --git a/app/helpers/triggers_helper.rb b/app/helpers/triggers_helper.rb index ce435ca2241..5cfdc0971f0 100644 --- a/app/helpers/triggers_helper.rb +++ b/app/helpers/triggers_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module TriggersHelper def builds_trigger_url(project_id, ref: nil) if ref.nil? diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb index da5fe25c07d..bae01d476df 100644 --- a/app/helpers/user_callouts_helper.rb +++ b/app/helpers/user_callouts_helper.rb @@ -1,6 +1,9 @@ +# frozen_string_literal: true + module UserCalloutsHelper GKE_CLUSTER_INTEGRATION = 'gke_cluster_integration'.freeze GCP_SIGNUP_OFFER = 'gcp_signup_offer'.freeze + CLUSTER_SECURITY_WARNING = 'cluster_security_warning'.freeze def show_gke_cluster_integration_callout?(project) can?(current_user, :create_cluster, project) && @@ -11,6 +14,10 @@ module UserCalloutsHelper !user_dismissed?(GCP_SIGNUP_OFFER) end + def show_cluster_security_warning? + !user_dismissed?(CLUSTER_SECURITY_WARNING) + end + private def user_dismissed?(feature_name) diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 2c0c4254a0c..bcd91f619c8 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module UsersHelper def user_link(user) link_to(user.name, user_path(user), diff --git a/app/helpers/version_check_helper.rb b/app/helpers/version_check_helper.rb index c20753ece72..75637eb0676 100644 --- a/app/helpers/version_check_helper.rb +++ b/app/helpers/version_check_helper.rb @@ -1,8 +1,12 @@ +# frozen_string_literal: true + module VersionCheckHelper def version_status_badge - if Rails.env.production? && Gitlab::CurrentSettings.version_check_enabled - image_url = VersionCheck.new.url - image_tag image_url, class: 'js-version-status-badge' - end + return unless Rails.env.production? + return unless Gitlab::CurrentSettings.version_check_enabled + return if User.single_user&.requires_usage_stats_consent? + + image_url = VersionCheck.new.url + image_tag image_url, class: 'js-version-status-badge' end end diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb index 7b64869c9ea..e690350a0d1 100644 --- a/app/helpers/visibility_level_helper.rb +++ b/app/helpers/visibility_level_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module VisibilityLevelHelper def visibility_level_color(level) case level @@ -82,7 +84,7 @@ module VisibilityLevelHelper def disallowed_project_visibility_level_description(level, project) level_name = Gitlab::VisibilityLevel.level_name(level).downcase reasons = [] - instructions = '' + instructions = [] unless project.visibility_level_allowed_as_fork?(level) reasons << "the fork source project has lower visibility" @@ -96,7 +98,7 @@ module VisibilityLevelHelper end reasons = reasons.any? ? ' because ' + reasons.to_sentence : '' - "This project cannot be #{level_name}#{reasons}.#{instructions}".html_safe + "This project cannot be #{level_name}#{reasons}.#{instructions.join}".html_safe end # Note: these messages closely mirror the form validation strings found in the group @@ -104,7 +106,7 @@ module VisibilityLevelHelper def disallowed_group_visibility_level_description(level, group) level_name = Gitlab::VisibilityLevel.level_name(level).downcase reasons = [] - instructions = '' + instructions = [] unless group.visibility_level_allowed_by_projects?(level) reasons << "it contains projects with higher visibility" @@ -122,7 +124,7 @@ module VisibilityLevelHelper end reasons = reasons.any? ? ' because ' + reasons.to_sentence : '' - "This group cannot be #{level_name}#{reasons}.#{instructions}".html_safe + "This group cannot be #{level_name}#{reasons}.#{instructions.join}".html_safe end def visibility_icon_description(form_model) diff --git a/app/helpers/webpack_helper.rb b/app/helpers/webpack_helper.rb index 72f6b397046..345ddcf023a 100644 --- a/app/helpers/webpack_helper.rb +++ b/app/helpers/webpack_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module WebpackHelper def webpack_bundle_tag(bundle) javascript_include_tag(*webpack_entrypoint_paths(bundle)) diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb index 17940aeb900..647f34e57ed 100644 --- a/app/helpers/wiki_helper.rb +++ b/app/helpers/wiki_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module WikiHelper include API::Helpers::RelatedResourcesHelpers diff --git a/app/helpers/workhorse_helper.rb b/app/helpers/workhorse_helper.rb index fd1d78bd9b8..f19445fca1a 100644 --- a/app/helpers/workhorse_helper.rb +++ b/app/helpers/workhorse_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Helpers to send Git blobs, diffs, patches or archives through Workhorse. # Workhorse will also serve files when using `send_file`. module WorkhorseHelper diff --git a/app/mailers/emails/auto_devops.rb b/app/mailers/emails/auto_devops.rb new file mode 100644 index 00000000000..9705a3052d4 --- /dev/null +++ b/app/mailers/emails/auto_devops.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Emails + module AutoDevops + def autodevops_disabled_email(pipeline, recipient) + @pipeline = pipeline + @project = pipeline.project + + add_project_headers + + mail(to: recipient, + subject: auto_devops_disabled_subject(@project.name)) do |format| + format.html { render layout: 'mailer' } + format.text { render layout: 'mailer' } + end + end + + private + + def auto_devops_disabled_subject(project_name) + subject("Auto DevOps pipeline was disabled for #{project_name}") + end + end +end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index f4eeb85270e..f7347ee61b4 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -12,6 +12,7 @@ class Notify < BaseMailer include Emails::Profile include Emails::Pipelines include Emails::Members + include Emails::AutoDevops helper MergeRequestsHelper helper DiffHelper diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index df470930e9e..c133f4e6dbb 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -125,6 +125,10 @@ class NotifyPreview < ActionMailer::Preview Notify.pipeline_failed_email(pipeline, pipeline.user.try(:email)) end + def autodevops_disabled_email + Notify.autodevops_disabled_email(pipeline, user.email).message + end + private def project diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 03bd7fa016e..645adddb000 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -219,6 +219,7 @@ class ApplicationSetting < ActiveRecord::Base validate :terms_exist, if: :enforce_terms? before_validation :ensure_uuid! + before_validation :strip_sentry_values before_save :ensure_runners_registration_token before_save :ensure_health_check_access_token @@ -302,7 +303,8 @@ class ApplicationSetting < ActiveRecord::Base instance_statistics_visibility_private: false, user_default_external: false, user_default_internal_regex: nil, - user_show_add_ssh_key_message: true + user_show_add_ssh_key_message: true, + usage_stats_set_by_user_id: nil } end @@ -381,6 +383,11 @@ class ApplicationSetting < ActiveRecord::Base super(levels.map { |level| Gitlab::VisibilityLevel.level_value(level) }) end + def strip_sentry_values + sentry_dsn.strip! if sentry_dsn.present? + clientside_sentry_dsn.strip! if clientside_sentry_dsn.present? + end + def performance_bar_allowed_group Group.find_by_id(performance_bar_allowed_group_id) end diff --git a/app/models/blob_viewer/gitlab_ci_yml.rb b/app/models/blob_viewer/gitlab_ci_yml.rb index 1a86f04b1b9..655241c2808 100644 --- a/app/models/blob_viewer/gitlab_ci_yml.rb +++ b/app/models/blob_viewer/gitlab_ci_yml.rb @@ -10,16 +10,16 @@ module BlobViewer self.file_types = %i(gitlab_ci) self.binary = false - def validation_message + def validation_message(project, sha) return @validation_message if defined?(@validation_message) prepare! - @validation_message = Gitlab::Ci::YamlProcessor.validation_message(blob.data) + @validation_message = Gitlab::Ci::YamlProcessor.validation_message(blob.data, { project: project, sha: sha }) end - def valid? - validation_message.blank? + def valid?(project, sha) + validation_message(project, sha).blank? end end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index faa160ad6ba..cc1408cb397 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -40,6 +40,7 @@ module Ci delegate :url, to: :runner_session, prefix: true, allow_nil: true delegate :terminal_specification, to: :runner_session, allow_nil: true delegate :gitlab_deploy_token, to: :project + delegate :trigger_short_token, to: :trigger_request, allow_nil: true ## # The "environment" field for builds is a String, and is the unexpanded name! diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 526bf7af99b..8c075253400 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -161,6 +161,12 @@ module Ci PipelineNotificationWorker.perform_async(pipeline.id) end end + + after_transition any => [:failed] do |pipeline| + next unless pipeline.auto_devops_source? + + pipeline.run_after_commit { AutoDevops::DisableWorker.perform_async(pipeline.id) } + end end scope :internal, -> { where(source: internal_sources) } @@ -458,7 +464,7 @@ module Ci return @config_processor if defined?(@config_processor) @config_processor ||= begin - Gitlab::Ci::YamlProcessor.new(ci_yaml_file) + ::Gitlab::Ci::YamlProcessor.new(ci_yaml_file, { project: project, sha: sha }) rescue Gitlab::Ci::YamlProcessor::ValidationError => e self.yaml_errors = e.message nil diff --git a/app/models/ci/trigger_request.rb b/app/models/ci/trigger_request.rb index 913936a0bcb..0b52c690e93 100644 --- a/app/models/ci/trigger_request.rb +++ b/app/models/ci/trigger_request.rb @@ -8,6 +8,8 @@ module Ci belongs_to :pipeline, foreign_key: :commit_id has_many :builds + delegate :short_token, to: :trigger, prefix: true, allow_nil: true + # We switched to Ci::PipelineVariable from Ci::TriggerRequest.variables. # Ci::TriggerRequest doesn't save variables anymore. validates :variables, absence: true diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb index 3d84eeed5a8..2371b0237d8 100644 --- a/app/models/clusters/applications/jupyter.rb +++ b/app/models/clusters/applications/jupyter.rb @@ -73,10 +73,19 @@ module Clusters "clientSecret" => oauth_application.secret, "callbackUrl" => callback_url } + }, + "singleuser" => { + "extraEnv" => { + "GITLAB_PROJECT_ID" => project_id + } } } end + def project_id + cluster&.project&.id + end + def gitlab_url Gitlab.config.gitlab.url end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 7f14d78e976..5f65fceb7af 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -109,10 +109,6 @@ module Issuable false end - def etag_caching_enabled? - false - end - def has_multiple_assignees? assignees.count > 1 end diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index ce778eae271..098eed137ba 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -82,4 +82,23 @@ module Noteable def lockable? [MergeRequest, Issue].include?(self.class) end + + def etag_caching_enabled? + false + end + + def expire_note_etag_cache + return unless discussions_rendered_on_frontend? + return unless etag_caching_enabled? + + Gitlab::EtagCaching::Store.new.touch(note_etag_key) + end + + def note_etag_key + Gitlab::Routing.url_helpers.project_noteable_notes_path( + project, + target_type: self.class.name.underscore, + target_id: id + ) + end end diff --git a/app/models/concerns/project_services_loggable.rb b/app/models/concerns/project_services_loggable.rb new file mode 100644 index 00000000000..fecd77cdc98 --- /dev/null +++ b/app/models/concerns/project_services_loggable.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module ProjectServicesLoggable + def log_info(message, params = {}) + message = build_message(message, params) + + logger.info(message) + end + + def log_error(message, params = {}) + message = build_message(message, params) + + logger.error(message) + end + + def build_message(message, params = {}) + { + service_class: self.class.name, + project_id: project.id, + project_path: project.full_path, + message: message + }.merge(params) + end + + def logger + Gitlab::ProjectServiceLogger + end +end diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb index 3b745657a9e..9785011720a 100644 --- a/app/models/concerns/storage/legacy_namespace.rb +++ b/app/models/concerns/storage/legacy_namespace.rb @@ -25,8 +25,6 @@ module Storage Gitlab::PagesTransfer.new.rename_namespace(full_path_was, full_path) end - remove_exports! - # If repositories moved successfully we need to # send update instructions to users. # However we cannot allow rollback since we moved namespace dir @@ -101,8 +99,6 @@ module Storage end end end - - remove_exports! end def remove_legacy_exports! diff --git a/app/models/event.rb b/app/models/event.rb index ba28866e8e6..041dac6941b 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -151,15 +151,17 @@ class Event < ActiveRecord::Base if push? || commit_note? Ability.allowed?(user, :download_code, project) elsif membership_changed? - true + Ability.allowed?(user, :read_project, project) elsif created_project? - true + Ability.allowed?(user, :read_project, project) elsif issue? || issue_note? Ability.allowed?(user, :read_issue, note? ? note_target : target) elsif merge_request? || merge_request_note? Ability.allowed?(user, :read_merge_request, note? ? note_target : target) + elsif milestone? + Ability.allowed?(user, :read_project, project) else - milestone? + false # No other event types are visible end end diff --git a/app/models/hooks/active_hook_filter.rb b/app/models/hooks/active_hook_filter.rb index ea046bea368..283e2d680f4 100644 --- a/app/models/hooks/active_hook_filter.rb +++ b/app/models/hooks/active_hook_filter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ActiveHookFilter def initialize(hook) @hook = hook diff --git a/app/models/label.rb b/app/models/label.rb index 96c1515b41a..8dc7ded53ad 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -5,6 +5,8 @@ class Label < ActiveRecord::Base include Referable include Subscribable include Gitlab::SQL::Pattern + include OptionallySearch + include Sortable # Represents a "No Label" state used for filtering Issues and Merge # Requests that have no label assigned. @@ -40,6 +42,8 @@ class Label < ActiveRecord::Base scope :with_lists_and_board, -> { joins(lists: :board).merge(List.movable) } scope :on_group_boards, ->(group_id) { with_lists_and_board.where(boards: { group_id: group_id }) } scope :on_project_boards, ->(project_id) { with_lists_and_board.where(boards: { project_id: project_id }) } + scope :order_name_asc, -> { reorder(title: :asc) } + scope :order_name_desc, -> { reorder(title: :desc) } def self.prioritized(project) joins(:priorities) diff --git a/app/models/label_note.rb b/app/models/label_note.rb new file mode 100644 index 00000000000..680952cf421 --- /dev/null +++ b/app/models/label_note.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +class LabelNote < Note + attr_accessor :resource_parent + attr_reader :events + + def self.from_events(events, resource: nil, resource_parent: nil) + resource ||= events.first.issuable + + attrs = { + system: true, + author: events.first.user, + created_at: events.first.created_at, + discussion_id: events.first.discussion_id, + noteable: resource, + system_note_metadata: SystemNoteMetadata.new(action: 'label'), + events: events, + resource_parent: resource_parent + } + + if resource_parent.is_a?(Project) + attrs[:project_id] = resource_parent.id + end + + LabelNote.new(attrs) + end + + def events=(events) + @events = events + + update_outdated_markdown + end + + def cached_html_up_to_date?(markdown_field) + true + end + + def note + @note ||= note_text + end + + def note_html + @note_html ||= "<p dir=\"auto\">#{note_text(html: true)}</p>" + end + + def project + resource_parent if resource_parent.is_a?(Project) + end + + def group + resource_parent if resource_parent.is_a?(Group) + end + + private + + def update_outdated_markdown + events.each do |event| + if event.outdated_markdown? + event.refresh_invalid_reference + end + end + end + + def note_text(html: false) + added = labels_str('added', label_refs_by_action('add', html)) + removed = labels_str('removed', label_refs_by_action('remove', html)) + + [added, removed].compact.join(' and ') + end + + # returns string containing added/removed labels including + # count of deleted labels: + # + # added ~1 ~2 + 1 deleted label + # added 3 deleted labels + # added ~1 ~2 labels + def labels_str(prefix, label_refs) + existing_refs = label_refs.select { |ref| ref.present? }.sort + refs_str = existing_refs.empty? ? nil : existing_refs.join(' ') + + deleted = label_refs.count - existing_refs.count + deleted_str = deleted == 0 ? nil : "#{deleted} deleted" + + return nil unless refs_str || deleted_str + + label_list_str = [refs_str, deleted_str].compact.join(' + ') + suffix = 'label'.pluralize(deleted > 0 ? deleted : existing_refs.count) + + "#{prefix} #{label_list_str} #{suffix}" + end + + def label_refs_by_action(action, html) + field = html ? :reference_html : :reference + + events.select { |e| e.action == action }.map(&field) + end +end diff --git a/app/models/license_template.rb b/app/models/license_template.rb index 0ad75b27827..693a6a89fd2 100644 --- a/app/models/license_template.rb +++ b/app/models/license_template.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class LicenseTemplate PROJECT_TEMPLATE_REGEX = %r{[\<\{\[] diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 0deb44d7916..76920c3c039 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -253,18 +253,6 @@ class Namespace < ActiveRecord::Base end end - # Exports belonging to projects with legacy storage are placed in a common - # subdirectory of the namespace, so a simple `rm -rf` is sufficient to remove - # them. - # - # Exports of projects using hashed storage are placed in a location defined - # only by the project ID, so each must be removed individually. - def remove_exports! - remove_legacy_exports! - - all_projects.with_storage_feature(:repository).find_each(&:remove_exports) - end - def refresh_project_authorizations owner.refresh_authorized_projects end diff --git a/app/models/note.rb b/app/models/note.rb index 2e343b8f9f8..8f090cc31e6 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -389,18 +389,7 @@ class Note < ActiveRecord::Base end def expire_etag_cache - return unless noteable&.discussions_rendered_on_frontend? - return unless noteable&.etag_caching_enabled? - - Gitlab::EtagCaching::Store.new.touch(etag_key) - end - - def etag_key - Gitlab::Routing.url_helpers.project_noteable_notes_path( - project, - target_type: noteable_type.underscore, - target_id: noteable_id - ) + noteable&.expire_note_etag_cache end def touch(*args) diff --git a/app/models/project.rb b/app/models/project.rb index 97d9fa355ef..f057c63afdf 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -232,6 +232,8 @@ class Project < ActiveRecord::Base has_many :clusters, through: :cluster_project, class_name: 'Clusters::Cluster' has_many :cluster_ingresses, through: :clusters, source: :application_ingress, class_name: 'Clusters::Applications::Ingress' + has_many :prometheus_metrics + # Container repositories need to remove data from the container registry, # which is not managed by the DB. Hence we're still using dependent: :destroy # here. @@ -567,7 +569,6 @@ class Project < ActiveRecord::Base end def cleanup - @repository&.cleanup @repository = nil end @@ -1733,16 +1734,12 @@ class Project < ActiveRecord::Base import_export_shared.archive_path end - def export_project_path - Dir.glob("#{export_path}/*export.tar.gz").max_by { |f| File.ctime(f) } - end - def export_status if export_in_progress? :started elsif after_export_in_progress? :after_export_action - elsif export_project_path || export_project_object_exists? + elsif export_file_exists? :finished else :none @@ -1757,21 +1754,19 @@ class Project < ActiveRecord::Base import_export_shared.after_export_in_progress? end - def remove_exports(path = export_path) - if path.present? - FileUtils.rm_rf(path) - elsif export_project_object_exists? - import_export_upload.remove_export_file! - import_export_upload.save - end + def remove_exports + return unless export_file_exists? + + import_export_upload.remove_export_file! + import_export_upload.save end - def remove_exported_project_file - remove_exports(export_project_path) + def export_file_exists? + export_file&.file end - def export_project_object_exists? - Gitlab::ImportExport.object_storage? && import_export_upload&.export_file&.file + def export_file + import_export_upload&.export_file end def full_path_slug @@ -2067,6 +2062,12 @@ class Project < ActiveRecord::Base auto_cancel_pending_pipelines == 'enabled' end + # Update the default branch querying the remote to determine its HEAD + def update_root_ref(remote_name) + root_ref = repository.find_remote_root_ref(remote_name) + change_head(root_ref) if root_ref.present? && root_ref != default_branch + end + private def rename_or_migrate_repository! diff --git a/app/models/project_services/asana_service.rb b/app/models/project_services/asana_service.rb index 35c19049c04..568f870c2db 100644 --- a/app/models/project_services/asana_service.rb +++ b/app/models/project_services/asana_service.rb @@ -101,7 +101,7 @@ http://app.asana.com/-/account_api' task.update(completed: true) end rescue => e - Rails.logger.error(e.message) + log_error(e.message) next end end diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb index a783a314071..a15780c14f9 100644 --- a/app/models/project_services/irker_service.rb +++ b/app/models/project_services/irker_service.rb @@ -104,7 +104,7 @@ class IrkerService < Service new_recipient = URI.join(default_irc_uri, '/', recipient).to_s uri = consider_uri(URI.parse(new_recipient)) rescue - Rails.logger.error("Unable to create a valid URL from #{default_irc_uri} and #{recipient}") + log_error("Unable to create a valid URL", default_irc_uri: default_irc_uri, recipient: recipient) end end diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb index c7520d766a8..e1d342be188 100644 --- a/app/models/project_services/issue_tracker_service.rb +++ b/app/models/project_services/issue_tracker_service.rb @@ -88,7 +88,7 @@ class IssueTrackerService < Service rescue Gitlab::HTTP::Error, Timeout::Error, SocketError, Errno::ECONNRESET, Errno::ECONNREFUSED, OpenSSL::SSL::SSLError => error message = "#{self.type} had an error when trying to connect to #{self.project_url}: #{error.message}" end - Rails.logger.info(message) + log_info(message) result end diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index cc98b3f5a41..ba7fcb0cf93 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -205,7 +205,7 @@ class JiraService < IssueTrackerService begin issue.transitions.build.save!(transition: { id: transition_id }) rescue => error - Rails.logger.info "#{self.class.name} Issue Transition failed message ERROR: #{client_url} - #{error.message}" + log_error("Issue transition failed", error: error.message, client_url: client_url) return false end end @@ -257,9 +257,8 @@ class JiraService < IssueTrackerService new_remote_link.save!(remote_link_props) end - result_message = "#{self.class.name} SUCCESS: Successfully posted to #{client_url}." - Rails.logger.info(result_message) - result_message + log_info("Successfully posted", client_url: client_url) + "SUCCESS: Successfully posted to http://jira.example.net." end end @@ -317,7 +316,7 @@ class JiraService < IssueTrackerService rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => e @error = e.message - Rails.logger.info "#{self.class.name} Send message ERROR: #{client_url} - #{@error}" + log_error("Error sending message", client_url: client_url, error: @error) nil end diff --git a/app/models/prometheus_metric.rb b/app/models/prometheus_metric.rb new file mode 100644 index 00000000000..ce2db9cb44c --- /dev/null +++ b/app/models/prometheus_metric.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +class PrometheusMetric < ActiveRecord::Base + belongs_to :project, validate: true, inverse_of: :prometheus_metrics + + enum group: { + # built-in groups + nginx_ingress: -1, + ha_proxy: -2, + aws_elb: -3, + nginx: -4, + kubernetes: -5, + + # custom/user groups + business: 0, + response: 1, + system: 2 + } + + validates :title, presence: true + validates :query, presence: true + validates :group, presence: true + validates :y_label, presence: true + validates :unit, presence: true + + validates :project, presence: true, unless: :common? + validates :project, absence: true, if: :common? + + scope :common, -> { where(common: true) } + + GROUP_TITLES = { + # built-in groups + nginx_ingress: _('Response metrics (NGINX Ingress)'), + ha_proxy: _('Response metrics (HA Proxy)'), + aws_elb: _('Response metrics (AWS ELB)'), + nginx: _('Response metrics (NGINX)'), + kubernetes: _('System metrics (Kubernetes)'), + + # custom/user groups + business: _('Business metrics (Custom)'), + response: _('Response metrics (Custom)'), + system: _('System metrics (Custom)') + }.freeze + + REQUIRED_METRICS = { + nginx_ingress: %w(nginx_upstream_responses_total nginx_upstream_response_msecs_avg), + ha_proxy: %w(haproxy_frontend_http_requests_total haproxy_frontend_http_responses_total), + aws_elb: %w(aws_elb_request_count_sum aws_elb_latency_average aws_elb_httpcode_backend_5_xx_sum), + nginx: %w(nginx_server_requests nginx_server_requestMsec), + kubernetes: %w(container_memory_usage_bytes container_cpu_usage_seconds_total) + }.freeze + + def group_title + GROUP_TITLES[group.to_sym] + end + + def required_metrics + REQUIRED_METRICS[group.to_sym].to_a.map(&:to_s) + end + + def to_query_metric + Gitlab::Prometheus::Metric.new(id: id, title: title, required_metrics: required_metrics, weight: 0, y_label: y_label, queries: queries) + end + + def queries + [ + { + query_range: query, + unit: unit, + label: legend, + series: query_series + }.compact + ] + end + + def query_series + case legend + when 'Status Code' + [{ + label: 'status_code', + when: [ + { value: '2xx', color: 'green' }, + { value: '4xx', color: 'orange' }, + { value: '5xx', color: 'red' } + ] + }] + end + end +end diff --git a/app/models/repository.rb b/app/models/repository.rb index 929d28b9d88..ad65881ff43 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -24,6 +24,7 @@ class Repository delegate :ref_name_for_sha, to: :raw_repository delegate :bundle_to_disk, to: :raw_repository + delegate :find_remote_root_ref, to: :raw_repository CreateTreeError = Class.new(StandardError) @@ -81,10 +82,6 @@ class Repository alias_method :raw, :raw_repository - def cleanup - @raw_repository&.cleanup - end - # Don't use this! It's going away. Use Gitaly to read or write from repos. def path_to_repo @path_to_repo ||= @@ -999,14 +996,6 @@ class Repository remote_branch: merge_request.target_branch) end - def blob_data_at(sha, path) - blob = blob_at(sha, path) - return unless blob - - blob.load_all_data! - blob.data - end - def squash(user, merge_request) raw.squash(user, merge_request.id, branch: merge_request.target_branch, start_sha: merge_request.diff_start_sha, @@ -1015,6 +1004,14 @@ class Repository message: merge_request.title) end + def blob_data_at(sha, path) + blob = blob_at(sha, path) + return unless blob + + blob.load_all_data! + blob.data + end + private # TODO Generice finder, later split this on finders by Ref or Oid diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb index 42c255fcd1e..3fd96b9dc18 100644 --- a/app/models/resource_label_event.rb +++ b/app/models/resource_label_event.rb @@ -3,33 +3,122 @@ # This model is not used yet, it will be used for: # https://gitlab.com/gitlab-org/gitlab-ce/issues/48483 class ResourceLabelEvent < ActiveRecord::Base + include Importable + include Gitlab::Utils::StrongMemoize + include CacheMarkdownField + + cache_markdown_field :reference + belongs_to :user belongs_to :issue belongs_to :merge_request belongs_to :label - validates :user, presence: true, on: :create - validates :label, presence: true, on: :create + scope :created_after, ->(time) { where('created_at > ?', time) } + + validates :user, presence: { unless: :importing? }, on: :create + validates :label, presence: { unless: :importing? }, on: :create validate :exactly_one_issuable + after_save :expire_etag_cache + after_destroy :expire_etag_cache + enum action: { add: 1, remove: 2 } - def self.issuable_columns - %i(issue_id merge_request_id).freeze + def self.issuable_attrs + %i(issue merge_request).freeze end def issuable issue || merge_request end + # create same discussion id for all actions with the same user and time + def discussion_id(resource = nil) + strong_memoize(:discussion_id) do + Digest::SHA1.hexdigest([self.class.name, created_at, user_id].join("-")) + end + end + + def project + issuable.project + end + + def group + issuable.group if issuable.respond_to?(:group) + end + + def outdated_markdown? + return true if label_id.nil? && reference.present? + + reference.nil? || latest_cached_markdown_version != cached_markdown_version + end + + def banzai_render_context(field) + super.merge(pipeline: 'label', only_path: true) + end + + def refresh_invalid_reference + # label_id could be nullified on label delete + self.reference = '' if label_id.nil? + + # reference is not set for events which were not rendered yet + self.reference ||= label_reference + + if changed? + save + elsif invalidated_markdown_cache? + refresh_markdown_cache! + end + end + private + def label_reference + if local_label? + label.to_reference(format: :id) + elsif label.is_a?(GroupLabel) + label.to_reference(label.group, target_project: resource_parent, format: :id) + else + label.to_reference(resource_parent, format: :id) + end + end + def exactly_one_issuable - if self.class.issuable_columns.count { |attr| self[attr] } != 1 - errors.add(:base, "Exactly one of #{self.class.issuable_columns.join(', ')} is required") + issuable_count = self.class.issuable_attrs.count { |attr| self["#{attr}_id"] } + + return true if issuable_count == 1 + + # if none of issuable IDs is set, check explicitly if nested issuable + # object is set, this is used during project import + if issuable_count == 0 && importing? + issuable_count = self.class.issuable_attrs.count { |attr| self.public_send(attr) } # rubocop:disable GitlabSecurity/PublicSend + + return true if issuable_count == 1 end + + errors.add(:base, "Exactly one of #{self.class.issuable_attrs.join(', ')} is required") + end + + def expire_etag_cache + issuable.expire_note_etag_cache + end + + def local_label? + params = { include_ancestor_groups: true } + if resource_parent.is_a?(Project) + params[:project_id] = resource_parent.id + else + params[:group_id] = resource_parent.id + end + + LabelsFinder.new(nil, params).execute(skip_authorization: true).where(id: label.id).any? + end + + def resource_parent + issuable.project || issuable.group end end diff --git a/app/models/service.rb b/app/models/service.rb index 140058771ee..4dbda7acab6 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -5,6 +5,7 @@ class Service < ActiveRecord::Base include Sortable include Importable + include ProjectServicesLoggable serialize :properties, JSON # rubocop:disable Cop/ActiveRecordSerialize diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 376ef673ca8..6fadbcefa53 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -15,7 +15,7 @@ class SystemNoteMetadata < ActiveRecord::Base commit description merge confidential visible label assignee cross_reference title time_tracking branch milestone discussion task moved opened closed merged duplicate locked unlocked - outdated tag + outdated tag due_date ].freeze validates :note, presence: true diff --git a/app/models/user.rb b/app/models/user.rb index 0fcc952b5cd..568ec101016 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -490,6 +490,16 @@ class User < ActiveRecord::Base u.name = 'Ghost User' end end + + # Return true if there is only single non-internal user in the deployment, + # ghost user is ignored. + def single_user? + User.non_internal.limit(2).count == 1 + end + + def single_user + User.non_internal.first if single_user? + end end def full_path @@ -1287,6 +1297,10 @@ class User < ActiveRecord::Base !terms_accepted? end + def requires_usage_stats_consent? + !consented_usage_stats? && 7.days.ago > self.created_at && !has_current_license? && User.single_user? + end + # @deprecated alias_method :owned_or_masters_groups, :owned_or_maintainers_groups @@ -1301,6 +1315,14 @@ class User < ActiveRecord::Base private + def has_current_license? + false + end + + def consented_usage_stats? + Gitlab::CurrentSettings.usage_stats_set_by_user_id == self.id + end + def owned_projects_union Gitlab::SQL::Union.new([ Project.where(namespace: namespace), diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb index 97e955ace36..1cd05cf3aac 100644 --- a/app/models/user_callout.rb +++ b/app/models/user_callout.rb @@ -5,7 +5,8 @@ class UserCallout < ActiveRecord::Base enum feature_name: { gke_cluster_integration: 1, - gcp_signup_offer: 2 + gcp_signup_offer: 2, + cluster_security_warning: 3 } validates :user, presence: true diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb index a08f34e2335..65e77ea3f92 100644 --- a/app/presenters/commit_status_presenter.rb +++ b/app/presenters/commit_status_presenter.rb @@ -11,10 +11,16 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated runner_unsupported: 'Your runner is outdated, please upgrade your runner' }.freeze + private_constant :CALLOUT_FAILURE_MESSAGES + presents :build + def self.callout_failure_messages + CALLOUT_FAILURE_MESSAGES + end + def callout_failure_message - CALLOUT_FAILURE_MESSAGES.fetch(failure_reason.to_sym) + self.class.callout_failure_messages.fetch(failure_reason.to_sym) end def recoverable? diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb index b107fc26f18..6f8194d9856 100644 --- a/app/serializers/build_details_entity.rb +++ b/app/serializers/build_details_entity.rb @@ -59,6 +59,12 @@ class BuildDetailsEntity < JobEntity raw_project_job_path(project, build) end + expose :trigger, if: -> (*) { build.trigger_request } do + expose :trigger_short_token, as: :short_token + + expose :trigger_variables, as: :variables, using: TriggerVariableEntity + end + private def build_failed_issue_options diff --git a/app/serializers/status_entity.rb b/app/serializers/detailed_status_entity.rb index 306c30f0323..c772c807f76 100644 --- a/app/serializers/status_entity.rb +++ b/app/serializers/detailed_status_entity.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class StatusEntity < Grape::Entity +class DetailedStatusEntity < Grape::Entity include RequestAwareEntity expose :icon, :text, :label, :group @@ -8,6 +8,14 @@ class StatusEntity < Grape::Entity expose :has_details?, as: :has_details expose :details_path + expose :illustration do |status| + begin + status.illustration + rescue NotImplementedError + # ignored + end + end + expose :favicon do |status| Gitlab::Favicon.status_overlay(status.favicon) end diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb index ed09db0f3f4..ebe76c9fcda 100644 --- a/app/serializers/discussion_entity.rb +++ b/app/serializers/discussion_entity.rb @@ -6,6 +6,7 @@ class DiscussionEntity < Grape::Entity expose :id, :reply_id expose :position, if: -> (d, _) { d.diff_discussion? && !d.legacy_diff_discussion? } + expose :original_position, if: -> (d, _) { d.diff_discussion? && !d.legacy_diff_discussion? } expose :line_code, if: -> (d, _) { d.diff_discussion? } expose :expanded?, as: :expanded expose :active?, as: :active, if: -> (d, _) { d.diff_discussion? } diff --git a/app/serializers/job_entity.rb b/app/serializers/job_entity.rb index 7bc1d87dea5..26b29993fec 100644 --- a/app/serializers/job_entity.rb +++ b/app/serializers/job_entity.rb @@ -27,7 +27,7 @@ class JobEntity < Grape::Entity expose :playable?, as: :playable expose :created_at expose :updated_at - expose :detailed_status, as: :status, with: StatusEntity + expose :detailed_status, as: :status, with: DetailedStatusEntity expose :callout_message, if: -> (*) { failed? && !build.script_failure? } expose :recoverable, if: -> (*) { failed? } diff --git a/app/serializers/job_group_entity.rb b/app/serializers/job_group_entity.rb index 0941a9d36be..0db7624b3f7 100644 --- a/app/serializers/job_group_entity.rb +++ b/app/serializers/job_group_entity.rb @@ -5,7 +5,7 @@ class JobGroupEntity < Grape::Entity expose :name expose :size - expose :detailed_status, as: :status, with: StatusEntity + expose :detailed_status, as: :status, with: DetailedStatusEntity expose :jobs, with: JobEntity private diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index daa5c24d0f5..c6d27817411 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -4,6 +4,12 @@ class NoteEntity < API::Entities::Note include RequestAwareEntity include NotesHelper + expose :id do |note| + # resource events are represented as notes too, but don't + # have ID, discussion ID is used for them instead + note.id ? note.id.to_s : note.discussion_id + end + expose :type expose :author, using: NoteUserEntity @@ -46,8 +52,8 @@ class NoteEntity < API::Entities::Note expose :emoji_awardable?, as: :emoji_awardable expose :award_emoji, if: -> (note, _) { note.emoji_awardable? }, using: AwardEmojiEntity - expose :report_abuse_path do |note| - new_abuse_report_path(user_id: note.author.id, ref_url: Gitlab::UrlBuilder.build(note)) + expose :report_abuse_path, if: -> (note, _) { note.author_id } do |note| + new_abuse_report_path(user_id: note.author_id, ref_url: Gitlab::UrlBuilder.build(note)) end expose :noteable_note_url do |note| diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb index 6cf1925adda..aef838409e0 100644 --- a/app/serializers/pipeline_entity.rb +++ b/app/serializers/pipeline_entity.rb @@ -30,7 +30,7 @@ class PipelineEntity < Grape::Entity end expose :details do - expose :detailed_status, as: :status, with: StatusEntity + expose :detailed_status, as: :status, with: DetailedStatusEntity expose :duration expose :finished_at end diff --git a/app/serializers/project_note_entity.rb b/app/serializers/project_note_entity.rb index d7c4d0aacc6..f6cdea1d8b5 100644 --- a/app/serializers/project_note_entity.rb +++ b/app/serializers/project_note_entity.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class ProjectNoteEntity < NoteEntity - expose :human_access do |note| + expose :human_access, if: -> (note, _) { note.project.present? } do |note| note.project.team.human_max_access(note.author_id) end @@ -9,7 +9,7 @@ class ProjectNoteEntity < NoteEntity toggle_award_emoji_project_note_path(note.project, note.id) end - expose :path do |note| + expose :path, if: -> (note, _) { note.id } do |note| project_note_path(note.project, note) end diff --git a/app/serializers/stage_entity.rb b/app/serializers/stage_entity.rb index 00e6d32ee3a..ca8fa7e7877 100644 --- a/app/serializers/stage_entity.rb +++ b/app/serializers/stage_entity.rb @@ -19,7 +19,7 @@ class StageEntity < Grape::Entity latest_statuses end - expose :detailed_status, as: :status, with: StatusEntity + expose :detailed_status, as: :status, with: DetailedStatusEntity expose :path do |stage| project_pipeline_path( diff --git a/app/serializers/trigger_variable_entity.rb b/app/serializers/trigger_variable_entity.rb new file mode 100644 index 00000000000..56203113631 --- /dev/null +++ b/app/serializers/trigger_variable_entity.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class TriggerVariableEntity < Grape::Entity + include RequestAwareEntity + + expose :key, :value, :public +end diff --git a/app/services/application_settings/update_service.rb b/app/services/application_settings/update_service.rb index 19cf34e2ac4..2e4643ed668 100644 --- a/app/services/application_settings/update_service.rb +++ b/app/services/application_settings/update_service.rb @@ -11,11 +11,19 @@ module ApplicationSettings params[:performance_bar_allowed_group_id] = performance_bar_allowed_group_id end + if usage_stats_updated? && !params.delete(:skip_usage_stats_user) + params[:usage_stats_set_by_user_id] = current_user.id + end + @application_setting.update(@params) end private + def usage_stats_updated? + params.key?(:usage_ping_enabled) || params.key?(:version_check_enabled) + end + def update_terms(terms) return unless terms.present? diff --git a/app/services/emails/base_service.rb b/app/services/emails/base_service.rb index ba7b689a9af..988215ffc78 100644 --- a/app/services/emails/base_service.rb +++ b/app/services/emails/base_service.rb @@ -2,6 +2,8 @@ module Emails class BaseService + attr_reader :current_user + def initialize(current_user, params = {}) @current_user, @params = current_user, params.dup @user = params.delete(:user) diff --git a/app/services/emails/create_service.rb b/app/services/emails/create_service.rb index acf575e24e5..56925a724fe 100644 --- a/app/services/emails/create_service.rb +++ b/app/services/emails/create_service.rb @@ -3,7 +3,12 @@ module Emails class CreateService < ::Emails::BaseService def execute(extra_params = {}) - @user.emails.create(@params.merge(extra_params)) + skip_confirmation = @params.delete(:skip_confirmation) + + email = @user.emails.create(@params.merge(extra_params)) + + email&.confirm if skip_confirmation && current_user.admin? + email end end end diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb index 028b350ca07..765de9c66b0 100644 --- a/app/services/issuable/common_system_notes_service.rb +++ b/app/services/issuable/common_system_notes_service.rb @@ -17,6 +17,7 @@ module Issuable create_labels_note(old_labels) if issuable.labels != old_labels create_discussion_lock_note if issuable.previous_changes.include?('discussion_locked') create_milestone_note if issuable.previous_changes.include?('milestone_id') + create_due_date_note if issuable.previous_changes.include?('due_date') end private @@ -55,7 +56,9 @@ module Issuable added_labels = issuable.labels - old_labels removed_labels = old_labels - issuable.labels - SystemNoteService.change_label(issuable, issuable.project, current_user, added_labels, removed_labels) + ResourceEvents::ChangeLabelsService + .new(issuable, current_user) + .execute(added_labels: added_labels, removed_labels: removed_labels) end def create_title_change_note(old_title) @@ -88,6 +91,10 @@ module Issuable SystemNoteService.change_milestone(issuable, issuable.project, current_user, issuable.milestone) end + def create_due_date_note + SystemNoteService.change_due_date(issuable, issuable.project, current_user, issuable.due_date) + end + def create_discussion_lock_note SystemNoteService.discussion_lock(issuable, current_user) end diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index 841bce9949e..ec9d8944e4e 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -36,6 +36,7 @@ module Issues def update_new_issue rewrite_notes + copy_resource_label_events rewrite_issue_award_emoji add_note_moved_from end @@ -96,6 +97,18 @@ module Issues end end + def copy_resource_label_events + @old_issue.resource_label_events.find_in_batches do |batch| + events = batch.map do |event| + event.attributes + .except('id', 'reference', 'reference_html') + .merge('issue_id' => @new_issue.id, 'action' => ResourceLabelEvent.actions[event.action]) + end + + Gitlab::Database.bulk_insert(ResourceLabelEvent.table_name, events) + end + end + def rewrite_issue_award_emoji rewrite_award_emoji(@old_issue, @new_issue) end diff --git a/app/services/labels/promote_service.rb b/app/services/labels/promote_service.rb index 623a5f0950e..fcdcea2d0ea 100644 --- a/app/services/labels/promote_service.rb +++ b/app/services/labels/promote_service.rb @@ -13,6 +13,7 @@ module Labels label_ids_for_merge(new_label).find_in_batches(batch_size: BATCH_SIZE) do |batched_ids| update_issuables(new_label, batched_ids) + update_resource_label_events(new_label, batched_ids) update_issue_board_lists(new_label, batched_ids) update_priorities(new_label, batched_ids) subscribe_users(new_label, batched_ids) @@ -52,6 +53,12 @@ module Labels .update_all(label_id: new_label) end + def update_resource_label_events(new_label, label_ids) + ResourceLabelEvent + .where(label: label_ids) + .update_all(label_id: new_label) + end + def update_issue_board_lists(new_label, label_ids) List .where(label: label_ids) diff --git a/app/services/merge_requests/reload_diffs_service.rb b/app/services/merge_requests/reload_diffs_service.rb index 8d85dc9eb5f..1390ae0e199 100644 --- a/app/services/merge_requests/reload_diffs_service.rb +++ b/app/services/merge_requests/reload_diffs_service.rb @@ -30,7 +30,7 @@ module MergeRequests def clear_cache(new_diff) # Executing the iteration we cache highlighted diffs for each diff file of # MergeRequestDiff. - new_diff.diffs_collection.diff_files.to_a + new_diff.diffs_collection.write_cache # Remove cache for all diffs on this MR. Do not use the association on the # model, as that will interfere with other actions happening when @@ -38,7 +38,7 @@ module MergeRequests MergeRequestDiff.where(merge_request: merge_request).each do |merge_request_diff| next if merge_request_diff == new_diff - merge_request_diff.diffs_collection.clear_cache! + merge_request_diff.diffs_collection.clear_cache end end end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 4511c500fca..50fa373025b 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -407,6 +407,12 @@ class NotificationService end end + def autodevops_disabled(pipeline, recipients) + recipients.each do |recipient| + mailer.autodevops_disabled_email(pipeline, recipient).deliver_later + end + end + def pages_domain_verification_succeeded(domain) recipients_for_pages_domain(domain).each do |user| mailer.pages_domain_verification_succeeded_email(domain, user).deliver_later diff --git a/app/services/projects/auto_devops/disable_service.rb b/app/services/projects/auto_devops/disable_service.rb new file mode 100644 index 00000000000..9745ab67dbd --- /dev/null +++ b/app/services/projects/auto_devops/disable_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Projects + module AutoDevops + class DisableService < BaseService + def execute + return false unless implicitly_enabled_and_first_pipeline_failure? + + disable_auto_devops + end + + private + + def implicitly_enabled_and_first_pipeline_failure? + project.has_auto_devops_implicitly_enabled? && + first_pipeline_failure? + end + + # We're using `limit` to optimize `auto_devops pipeline` query, + # since we only care about the first element, and using only `.count` + # is an expensive operation. See + # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/21172#note_99037378 + # for more context. + def first_pipeline_failure? + auto_devops_pipelines.success.limit(1).count.zero? && + auto_devops_pipelines.failed.limit(1).count.nonzero? + end + + def disable_auto_devops + project.auto_devops_attributes = { enabled: false } + project.save! + end + + def auto_devops_pipelines + @auto_devops_pipelines ||= project.pipelines.auto_devops_source + end + end + end +end diff --git a/app/services/projects/container_repository/destroy_service.rb b/app/services/projects/container_repository/destroy_service.rb new file mode 100644 index 00000000000..a8e7eab6068 --- /dev/null +++ b/app/services/projects/container_repository/destroy_service.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Projects + module ContainerRepository + class DestroyService < BaseService + def execute(container_repository) + return false unless can?(current_user, :update_container_image, project) + + container_repository.destroy + end + end + end +end diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 76e22507698..01de6afcd8e 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -159,7 +159,7 @@ module Projects def remove_legacy_registry_tags return true unless Gitlab.config.registry.enabled - ContainerRepository.build_root_repository(project).tap do |repository| + ::ContainerRepository.build_root_repository(project).tap do |repository| break repository.has_tags? ? repository.delete_tags! : true end end diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb index 591b38b8151..85b9eb02803 100644 --- a/app/services/projects/update_remote_mirror_service.rb +++ b/app/services/projects/update_remote_mirror_service.rb @@ -5,13 +5,14 @@ module Projects attr_reader :errors def execute(remote_mirror) - @errors = [] - return success unless remote_mirror.enabled? + errors = [] + begin remote_mirror.ensure_remote! repository.fetch_remote(remote_mirror.remote_name, no_tags: true) + project.update_root_ref(remote_mirror.remote_name) opts = {} if remote_mirror.only_protected_branches? diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb index a4c4c9e4812..be9d1e48435 100644 --- a/app/services/quick_actions/interpret_service.rb +++ b/app/services/quick_actions/interpret_service.rb @@ -489,6 +489,30 @@ module QuickActions "#{comment} #{TABLEFLIP}" end + desc "Lock the discussion" + explanation "Locks the discussion" + condition do + issuable.is_a?(Issuable) && + issuable.persisted? && + !issuable.discussion_locked? && + current_user.can?(:"admin_#{issuable.to_ability_name}", issuable) + end + command :lock do + @updates[:discussion_locked] = true + end + + desc "Unlock the discussion" + explanation "Unlocks the discussion" + condition do + issuable.is_a?(Issuable) && + issuable.persisted? && + issuable.discussion_locked? && + current_user.can?(:"admin_#{issuable.to_ability_name}", issuable) + end + command :unlock do + @updates[:discussion_locked] = false + end + # This is a dummy command, so that it appears in the autocomplete commands desc 'CC' params '@user' diff --git a/app/services/resource_events/change_labels_service.rb b/app/services/resource_events/change_labels_service.rb index 8edb0ddb3ed..039d6e2ebad 100644 --- a/app/services/resource_events/change_labels_service.rb +++ b/app/services/resource_events/change_labels_service.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -# This service is not used yet, it will be used for: -# https://gitlab.com/gitlab-org/gitlab-ce/issues/48483 module ResourceEvents class ChangeLabelsService attr_reader :resource, :user @@ -25,6 +23,7 @@ module ResourceEvents end Gitlab::Database.bulk_insert(ResourceLabelEvent.table_name, labels) + resource.expire_note_etag_cache end private diff --git a/app/services/resource_events/merge_into_notes_service.rb b/app/services/resource_events/merge_into_notes_service.rb new file mode 100644 index 00000000000..1b02a1602e2 --- /dev/null +++ b/app/services/resource_events/merge_into_notes_service.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# We store events about issuable label changes in a separate table (not as +# other system notes), but we still want to display notes about label changes +# as classic system notes in UI. This service generates "synthetic" notes for +# label event changes and merges them with classic notes and sorts them by +# creation time. + +module ResourceEvents + class MergeIntoNotesService + include Gitlab::Utils::StrongMemoize + + attr_reader :resource, :current_user, :params + + def initialize(resource, current_user, params = {}) + @resource = resource + @current_user = current_user + @params = params + end + + def execute(notes = []) + (notes + label_notes).sort_by { |n| n.created_at } + end + + private + + def label_notes + label_events_by_discussion_id.map do |discussion_id, events| + LabelNote.from_events(events, resource: resource, resource_parent: resource_parent) + end + end + + def label_events_by_discussion_id + return [] unless resource.respond_to?(:resource_label_events) + + events = resource.resource_label_events.includes(:label, :user) + events = since_fetch_at(events) + + events.group_by { |event| event.discussion_id } + end + + def since_fetch_at(events) + return events unless params[:last_fetched_at].present? + + last_fetched_at = Time.at(params.fetch(:last_fetched_at).to_i) + events.created_after(last_fetched_at - NotesFinder::FETCH_OVERLAP) + end + + def resource_parent + strong_memoize(:resource_parent) do + resource.project || resource.group + end + end + end +end diff --git a/app/services/submit_usage_ping_service.rb b/app/services/submit_usage_ping_service.rb index 93c2e222963..62222d3fd2a 100644 --- a/app/services/submit_usage_ping_service.rb +++ b/app/services/submit_usage_ping_service.rb @@ -15,6 +15,7 @@ class SubmitUsagePingService def execute return false unless Gitlab::CurrentSettings.usage_ping_enabled? + return false if User.single_user&.requires_usage_stats_consent? response = Gitlab::HTTP.post( URL, diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index dda89830179..c5d05992575 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -98,66 +98,45 @@ module SystemNoteService create_note(NoteSummary.new(issue, project, author, body, action: 'assignee')) end - # Called when one or more labels on a Noteable are added and/or removed + # Called when the milestone of a Noteable is changed # - # noteable - Noteable object - # project - Project owning noteable - # author - User performing the change - # added_labels - Array of Labels added - # removed_labels - Array of Labels removed + # noteable - Noteable object + # project - Project owning noteable + # author - User performing the change + # milestone - Milestone being assigned, or nil # # Example Note text: # - # "added ~1 and removed ~2 ~3 labels" - # - # "added ~4 label" + # "removed milestone" # - # "removed ~5 label" + # "changed milestone to 7.11" # # Returns the created Note object - def change_label(noteable, project, author, added_labels, removed_labels) - labels_count = added_labels.count + removed_labels.count - - references = ->(label) { label.to_reference(format: :id) } - added_labels = added_labels.map(&references).join(' ') - removed_labels = removed_labels.map(&references).join(' ') - - text_parts = [] - - if added_labels.present? - text_parts << "added #{added_labels}" - text_parts << 'and' if removed_labels.present? - end - - if removed_labels.present? - text_parts << "removed #{removed_labels}" - end - - text_parts << 'label'.pluralize(labels_count) - body = text_parts.join(' ') + def change_milestone(noteable, project, author, milestone) + format = milestone&.group_milestone? ? :name : :iid + body = milestone.nil? ? 'removed milestone' : "changed milestone to #{milestone.to_reference(project, format: format)}" - create_note(NoteSummary.new(noteable, project, author, body, action: 'label')) + create_note(NoteSummary.new(noteable, project, author, body, action: 'milestone')) end - # Called when the milestone of a Noteable is changed + # Called when the due_date of a Noteable is changed # # noteable - Noteable object # project - Project owning noteable # author - User performing the change - # milestone - Milestone being assigned, or nil + # due_date - Due date being assigned, or nil # # Example Note text: # - # "removed milestone" + # "removed due date" # - # "changed milestone to 7.11" + # "changed due date to September 20, 2018" # # Returns the created Note object - def change_milestone(noteable, project, author, milestone) - format = milestone&.group_milestone? ? :name : :iid - body = milestone.nil? ? 'removed milestone' : "changed milestone to #{milestone.to_reference(project, format: format)}" + def change_due_date(noteable, project, author, due_date) + body = due_date ? "changed due date to #{due_date.to_s(:long)}" : 'removed due date' - create_note(NoteSummary.new(noteable, project, author, body, action: 'milestone')) + create_note(NoteSummary.new(noteable, project, author, body, action: 'due_date')) end # Called when the estimated time of a Noteable is changed diff --git a/app/services/wikis/create_attachment_service.rb b/app/services/wikis/create_attachment_service.rb index 30fe0e371a6..df31ad7c8ea 100644 --- a/app/services/wikis/create_attachment_service.rb +++ b/app/services/wikis/create_attachment_service.rb @@ -11,7 +11,7 @@ module Wikis def initialize(*args) super - @file_name = truncate_file_name(params[:file_name]) + @file_name = clean_file_name(params[:file_name]) @file_path = File.join(ATTACHMENT_PATH, SecureRandom.hex, @file_name) if @file_name @commit_message ||= "Upload attachment #{@file_name}" @branch_name ||= wiki.default_branch @@ -23,8 +23,16 @@ module Wikis private - def truncate_file_name(file_name) + def clean_file_name(file_name) return unless file_name.present? + + file_name = truncate_file_name(file_name) + # CommonMark does not allow Urls with whitespaces, so we have to replace them + # Using the same regex Carrierwave use to replace invalid characters + file_name.gsub(CarrierWave::SanitizedFile.sanitize_regexp, '_') + end + + def truncate_file_name(file_name) return file_name if file_name.length <= MAX_FILENAME_LENGTH extension = File.extname(file_name) diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb index b29ef57b071..c0165759203 100644 --- a/app/uploaders/avatar_uploader.rb +++ b/app/uploaders/avatar_uploader.rb @@ -18,6 +18,10 @@ class AvatarUploader < GitlabUploader false end + def absolute_path + self.class.absolute_path(model.avatar.upload) + end + private def dynamic_segment diff --git a/app/uploaders/namespace_file_uploader.rb b/app/uploaders/namespace_file_uploader.rb index 52969762b7d..4965bd7f057 100644 --- a/app/uploaders/namespace_file_uploader.rb +++ b/app/uploaders/namespace_file_uploader.rb @@ -6,23 +6,27 @@ class NamespaceFileUploader < FileUploader options.storage_path end - def self.base_dir(model, _store = nil) - File.join(options.base_dir, 'namespace', model_path_segment(model)) + def self.base_dir(model, store = nil) + base_dirs(model)[store || Store::LOCAL] + end + + def self.base_dirs(model) + { + Store::LOCAL => File.join(options.base_dir, 'namespace', model_path_segment(model)), + Store::REMOTE => File.join('namespace', model_path_segment(model)) + } end def self.model_path_segment(model) File.join(model.id.to_s) end + def self.workhorse_local_upload_path + File.join(options.storage_path, 'uploads', TMP_UPLOAD_PATH) + end + # Re-Override def store_dir store_dirs[object_store] end - - def store_dirs - { - Store::LOCAL => File.join(base_dir, dynamic_segment), - Store::REMOTE => File.join('namespace', self.class.model_path_segment(model), dynamic_segment) - } - end end diff --git a/app/validators/branch_filter_validator.rb b/app/validators/branch_filter_validator.rb index ef482aaaa63..6a0899be850 100644 --- a/app/validators/branch_filter_validator.rb +++ b/app/validators/branch_filter_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # BranchFilterValidator # # Custom validator for branch names. Squishes whitespace and ignores empty diff --git a/app/validators/js_regex_validator.rb b/app/validators/js_regex_validator.rb index a515af7b919..be715967b4a 100644 --- a/app/validators/js_regex_validator.rb +++ b/app/validators/js_regex_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class JsRegexValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) return true if value.blank? 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 9121e44d31b..10bc3452d8b 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -14,7 +14,10 @@ = f.label :max_attachment_size, 'Maximum attachment size (MB)', class: 'label-bold' = f.number_field :max_attachment_size, class: 'form-control' .form-group - = f.label :session_expire_delay, 'Session duration (minutes)', class: 'label-bold' + = f.label :receive_max_input_size, 'Maximum push size (MB)', class: 'label-light' + = f.number_field :receive_max_input_size, class: 'form-control' + .form-group + = 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 .form-group diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml index 94d33fa6489..2cdf98075d1 100644 --- a/app/views/admin/applications/index.html.haml +++ b/app/views/admin/applications/index.html.haml @@ -5,7 +5,7 @@ System OAuth applications don't belong to any user and can only be managed by admins %hr %p= link_to 'New application', new_admin_application_path, class: 'btn btn-success' -%table.table.table-striped +%table.table %thead %tr %th Name diff --git a/app/views/events/_event.html.haml b/app/views/events/_event.html.haml index 53a33adc14d..78a1d1a0553 100644 --- a/app/views/events/_event.html.haml +++ b/app/views/events/_event.html.haml @@ -11,3 +11,5 @@ = render "events/event/note", event: event - else = render "events/event/common", event: event +- elsif @user&.include_private_contributions? + = render "events/event/private", event: event diff --git a/app/views/events/_event_scope.html.haml b/app/views/events/_event_scope.html.haml index 8f7da7d8c4f..98941722434 100644 --- a/app/views/events/_event_scope.html.haml +++ b/app/views/events/_event_scope.html.haml @@ -1,7 +1,7 @@ %span.event-scope = event_preposition(event) - if event.project - = link_to_project event.project + = link_to_project(event.project) - else = event.project_name diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml index 01e72862114..829a3da1558 100644 --- a/app/views/events/event/_common.html.haml +++ b/app/views/events/event/_common.html.haml @@ -1,7 +1,7 @@ = icon_for_profile_event(event) .event-title - %span.author_name= link_to_author event + %span.author_name= link_to_author(event) %span{ class: event.action_name } - if event.target = event.action_name diff --git a/app/views/events/event/_created_project.html.haml b/app/views/events/event/_created_project.html.haml index d8e59be57bb..6ad7e157131 100644 --- a/app/views/events/event/_created_project.html.haml +++ b/app/views/events/event/_created_project.html.haml @@ -1,11 +1,11 @@ = icon_for_profile_event(event) .event-title - %span.author_name= link_to_author event + %span.author_name= link_to_author(event) %span{ class: event.action_name } = event_action_name(event) - if event.project - = link_to_project event.project + = link_to_project(event.project) - else = event.project_name diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml index de6383e4097..cdacd998a69 100644 --- a/app/views/events/event/_note.html.haml +++ b/app/views/events/event/_note.html.haml @@ -1,7 +1,7 @@ = icon_for_profile_event(event) .event-title - %span.author_name= link_to_author event + %span.author_name= link_to_author(event) = event.action_name = event_note_title_html(event) diff --git a/app/views/events/event/_private.html.haml b/app/views/events/event/_private.html.haml new file mode 100644 index 00000000000..ccd2aacb4ea --- /dev/null +++ b/app/views/events/event/_private.html.haml @@ -0,0 +1,10 @@ +.event-inline.event-item + .event-item-timestamp + = time_ago_with_tooltip(event.created_at) + + .system-note-image= sprite_icon('eye-slash', size: 16, css_class: 'icon') + + .event-title + - author_name = capture do + %span.author_name= link_to_author(event) + = s_('Profiles|%{author_name} made a private contribution').html_safe % { author_name: author_name } diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index 85f2d00bde3..5f0ee79cd9b 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -3,7 +3,7 @@ = icon_for_profile_event(event) .event-title - %span.author_name= link_to_author event + %span.author_name= link_to_author(event) %span.pushed #{event.action_name} #{event.ref_type} %strong - commits_link = project_commits_path(project, event.ref_name) diff --git a/app/views/groups/_archived_projects.html.haml b/app/views/groups/_archived_projects.html.haml new file mode 100644 index 00000000000..ed79f5790f0 --- /dev/null +++ b/app/views/groups/_archived_projects.html.haml @@ -0,0 +1,8 @@ +#js-groups-archived-tree + .empty-state.text-center.hidden + %p= _("There are no archived projects yet") + + %ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } } + .js-groups-list-holder + .loading-container.text-center + = icon('spinner spin 2x', class: 'loading-animation prepend-top-20') diff --git a/app/views/groups/_children.html.haml b/app/views/groups/_children.html.haml deleted file mode 100644 index 742b40784d3..00000000000 --- a/app/views/groups/_children.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -.js-groups-list-holder - #js-groups-tree{ data: { hide_projects: 'false', group_id: group.id, endpoint: group_children_path(group, format: :json), path: group_path(group), form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } } - .loading-container.text-center - = icon('spinner spin 2x', class: 'loading-animation prepend-top-20') diff --git a/app/views/groups/_shared_projects.html.haml b/app/views/groups/_shared_projects.html.haml new file mode 100644 index 00000000000..4eb8367f633 --- /dev/null +++ b/app/views/groups/_shared_projects.html.haml @@ -0,0 +1,8 @@ +#js-groups-shared-tree + .empty-state.text-center.hidden + %p= _("There are no projects shared with this group yet") + + %ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } } + .js-groups-list-holder + .loading-container.text-center + = icon('spinner spin 2x', class: 'loading-animation prepend-top-20') diff --git a/app/views/groups/_subgroups_and_projects.html.haml b/app/views/groups/_subgroups_and_projects.html.haml new file mode 100644 index 00000000000..d53c8026df8 --- /dev/null +++ b/app/views/groups/_subgroups_and_projects.html.haml @@ -0,0 +1,8 @@ +#js-groups-subgroups_and_projects-tree + .empty-state.hidden + = render "shared/groups/empty_state" + + %ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } } + .js-groups-list-holder + .loading-container.text-center + = icon('spinner spin 2x', class: 'loading-animation prepend-top-20') diff --git a/app/views/groups/labels/index.html.haml b/app/views/groups/labels/index.html.haml index e6821009d03..86178eb2ffd 100644 --- a/app/views/groups/labels/index.html.haml +++ b/app/views/groups/labels/index.html.haml @@ -22,6 +22,7 @@ %span.input-group-append %button.btn.btn-default{ type: "submit", "aria-label" => _('Submit search') } = icon("search") + = render 'shared/labels/sort_dropdown' .labels-container.prepend-top-5 - if @labels.any? diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 5a88619f769..f1bd817f17a 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -7,11 +7,10 @@ = render 'groups/home_panel' -.groups-header{ class: container_class } - .group-nav-container - .nav-controls.clearfix +.groups-listing{ class: container_class, data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } } + .top-area.group-nav-container + .group-search = render "shared/groups/search_form" - = render "shared/groups/dropdown", show_archive_options: true - if can? current_user, :create_projects, @group - new_project_label = _("New project") - new_subgroup_label = _("New subgroup") @@ -39,7 +38,29 @@ - else = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success" - - if params[:filter].blank? && !@has_children - = render "shared/groups/empty_state" - - else - = render "children", children: @children, group: @group + .scrolling-tabs-container.inner-page-scroll-tabs + .fade-left= icon('angle-left') + .fade-right= icon('angle-right') + %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs + %li.js-subgroups_and_projects-tab + = link_to group_path, data: { target: 'div#subgroups_and_projects', action: 'subgroups_and_projects', toggle: 'tab'} do + = _("Subgroups and projects") + %li.js-shared-tab + = link_to group_shared_path, data: { target: 'div#shared', action: 'shared', toggle: 'tab'} do + = _("Shared projects") + %li.js-archived-tab + = link_to group_archived_path, data: { target: 'div#archived', action: 'archived', toggle: 'tab'} do + = _("Archived projects") + + .nav-controls + = render "shared/groups/dropdown" + + .tab-content + #subgroups_and_projects.tab-pane + = render "subgroups_and_projects", group: @group + + #shared.tab-pane + = render "shared_projects", group: @group + + #archived.tab-pane + = render "archived_projects", group: @group diff --git a/app/views/import/gitlab_projects/new.html.haml b/app/views/import/gitlab_projects/new.html.haml index 4225ee19217..c4218f3d787 100644 --- a/app/views/import/gitlab_projects/new.html.haml +++ b/app/views/import/gitlab_projects/new.html.haml @@ -8,8 +8,11 @@ = form_tag import_gitlab_project_path, class: 'new_project', multipart: true do .row + .form-group.project-name.col-sm-12 + = label_tag :name, _('Project name'), class: 'label-bold' + = text_field_tag :name, @name, placeholder: "My awesome project", class: "js-project-name form-control input-lg", autofocus: true, required: true .form-group.col-12.col-sm-6 - = label_tag :namespace_id, 'Project path', class: 'label-bold' + = label_tag :namespace_id, _('Project URL'), class: 'label-bold' .form-group .input-group - if current_user.can_select_namespace? @@ -24,8 +27,8 @@ #{user_url(current_user.username)}/ = hidden_field_tag :namespace_id, value: current_user.namespace_id .form-group.col-12.col-sm-6.project-path - = label_tag :path, _('Project name'), class: 'label-bold' - = text_field_tag :path, @path, placeholder: "my-awesome-project", class: "js-path-name form-control", tabindex: 2, autofocus: true, required: true + = label_tag :path, _('Project slug'), class: 'label-bold' + = text_field_tag :path, @path, placeholder: "my-awesome-project", class: "js-path-name form-control", tabindex: 2, required: true .row .form-group.col-md-12 diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index f67a8878c80..a41d30da450 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -8,6 +8,7 @@ = render "layouts/broadcast" = render 'layouts/header/read_only_banner' = yield :flash_message + = render "shared/ping_consent" - unless @hide_breadcrumbs = render "layouts/nav/breadcrumbs" = render "layouts/flash" diff --git a/app/views/notify/_failed_builds.html.haml b/app/views/notify/_failed_builds.html.haml new file mode 100644 index 00000000000..7c563bb016c --- /dev/null +++ b/app/views/notify/_failed_builds.html.haml @@ -0,0 +1,32 @@ +%tr + %td{ colspan: 2, style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; color: #333333; font-size: 14px; font-weight: 400; line-height: 1.4; padding: 0 8px 16px; text-align: center;" } + had + = failed.size + failed + #{'build'.pluralize(failed.size)}. +%tr.table-warning + %td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; border: 1px solid #ededed; border-bottom: 0; border-radius: 4px 4px 0 0; overflow: hidden; background-color: #fdf4f6; color: #d22852; font-size: 14px; line-height: 1.4; text-align: center; padding: 8px 16px;" } + Logs may contain sensitive data. Please consider before forwarding this email. +%tr.section + %td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; padding: 0 16px; border: 1px solid #ededed; border-radius: 4px; overflow: hidden; border-top: 0; border-radius: 0 0 4px 4px;" } + %table.builds{ border: "0", cellpadding: "0", cellspacing: "0", style: "width: 100%; border-collapse: collapse;" } + %tbody + - failed.each do |build| + %tr.build-state + %td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; padding: 16px 0; color: #8c8c8c; font-weight: 500; font-size: 14px;" } + %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse: collapse;" } + %tbody + %tr + %td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; color: #d22f57; font-weight: 500; font-size: 16px; vertical-align: middle; padding-right: 8px; line-height: 10px" } + %img{ alt: "✖", height: "10", src: image_url('mailers/ci_pipeline_notif_v1/icon-x-red.gif'), style: "display: block;", width: "10" }/ + %td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; color: #8c8c8c; font-weight: 500; font-size: 14px; vertical-align: middle;" } + = build.stage + %td{ align: "right", style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; padding: 16px 0; color: #8c8c8c; font-weight: 500; font-size: 14px;" } + = render "notify/links/#{build.to_partial_path}", pipeline: pipeline, build: build + %tr.build-log + - if build.has_trace? + %td{ colspan: "2", style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; padding: 0 0 16px;" } + %pre{ style: "font-family: Monaco,'Lucida Console','Courier New',Courier,monospace; background-color: #fafafa; border-radius: 4px; overflow: hidden; white-space: pre-wrap; word-break: break-all; font-size:13px; line-height: 1.4; padding: 16px 8px; color: #333333; margin: 0;" } + = build.trace.html(last_lines: 10).html_safe + - else + %td{ colspan: "2" } diff --git a/app/views/notify/autodevops_disabled_email.html.haml b/app/views/notify/autodevops_disabled_email.html.haml new file mode 100644 index 00000000000..65a2f75a3e2 --- /dev/null +++ b/app/views/notify/autodevops_disabled_email.html.haml @@ -0,0 +1,49 @@ +%tr.alert + %td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; padding: 8px 16px; border-radius: 4px; font-size: 14px; line-height: 1.3; text-align: center; overflow: hidden; background-color: #d22f57; color: #ffffff;" } + %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse: collapse; margin: 0 auto;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif; vertical-align: middle; color: #ffffff; text-align: center;" } + Auto DevOps pipeline was disabled for #{@project.name} + +%tr.pre-section + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif; color: #333333; font-size: 14px; font-weight: 400; line-height: 1.7; padding: 16px 8px 0;" } + The Auto DevOps pipeline failed for pipeline + %a{ href: pipeline_url(@pipeline), style: "color: #1b69b6; text-decoration:none;" } + = "\##{@pipeline.iid}" + and has been disabled for + %a{ href: project_url(@project), style: "color: #1b69b6; text-decoration: none;" } + = @project.name + "." + In order to use the Auto DevOps pipeline with your project, please review the + %a{ href: 'https://docs.gitlab.com/ee/topics/autodevops/#currently-supported-languages', style: "color:#1b69b6;text-decoration:none;" } currently supported languages, + adjust your project accordingly, and turn on the Auto DevOps pipeline within your + %a{ href: project_settings_ci_cd_url(@project), style: "color: #1b69b6; text-decoration: none;" } + CI/CD project settings. + +%tr.pre-section + %td{ style: 'text-align: center;border-bottom:1px solid #ededed' } + %a{ href: 'https://docs.gitlab.com/ee/topics/autodevops/', style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" } + %button{ type: 'button', style: 'border-color: #dfdfdf; border-style: solid; border-width: 1px; border-radius: 4px; font-size: 14px; padding: 8px 16px; background-color:#fff; margin: 8px 0; cursor: pointer;' } + Learn more about Auto DevOps + +%tr.pre-section + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif; color: #333333; font-size: 14px; font-weight: 400; line-height: 1.4; padding: 16px 8px; text-align: center;" } + %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;margin:0 auto;" } + %tbody + %tr + %td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-size:14px; font-weight:500;line-height: 1.4; vertical-align: baseline;" } + Pipeline + %a{ href: pipeline_url(@pipeline), style: "color: #1b69b6; text-decoration: none;" } + = "\##{@pipeline.id}" + triggered by + - if @pipeline.user + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif; font-size: 15px; line-height: 1.4; vertical-align: middle; padding-right: 8px; padding-left:8px", width: "24" } + %img.avatar{ height: "24", src: avatar_icon_for_user(@pipeline.user, 24, only_path: false), style: "display: block; border-radius: 12px; margin: -2px 0;", width: "24", alt: "" }/ + %td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-size: 14px; font-weight: 500; line-height: 1.4; vertical-align: baseline;" } + %a.muted{ href: user_url(@pipeline.user), style: "color: #333333; text-decoration: none;" } + = @pipeline.user.name + - else + %td{ style: "font-family: 'Menlo','Liberation Mono','Consolas','DejaVu Sans Mono','Ubuntu Mono','Courier New','andale mono','lucida console',monospace; font-size: 14px; line-height: 1.4; vertical-align: baseline; padding:0 8px;" } + API + += render 'notify/failed_builds', pipeline: @pipeline, failed: @pipeline.statuses.latest.failed diff --git a/app/views/notify/autodevops_disabled_email.text.erb b/app/views/notify/autodevops_disabled_email.text.erb new file mode 100644 index 00000000000..695780c3145 --- /dev/null +++ b/app/views/notify/autodevops_disabled_email.text.erb @@ -0,0 +1,20 @@ +Auto DevOps pipeline was disabled for <%= @project.name %> + +The Auto DevOps pipeline failed for pipeline <%= @pipeline.iid %> (<%= pipeline_url(@pipeline) %>) and has been disabled for <%= @project.name %>. In order to use the Auto DevOps pipeline with your project, please review the currently supported languagues (https://docs.gitlab.com/ee/topics/autodevops/#currently-supported-languages), adjust your project accordingly, and turn on the Auto DevOps pipeline within your CI/CD project settings (<%= project_settings_ci_cd_url(@project) %>). + +<% if @pipeline.user -%> + Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= @pipeline.user.name %> ( <%= user_url(@pipeline.user) %> ) +<% else -%> + Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API +<% end -%> +<% failed = @pipeline.statuses.latest.failed -%> +had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>. + +<% failed.each do |build| -%> + <%= render "notify/links/#{build.to_partial_path}", pipeline: @pipeline, build: build %> + Stage: <%= build.stage %> + Name: <%= build.name %> + <% if build.has_trace? -%> + Trace: <%= build.trace.raw(last_lines: 10) %> + <% end -%> +<% end -%> diff --git a/app/views/notify/pipeline_failed_email.html.haml b/app/views/notify/pipeline_failed_email.html.haml index baafaa6e3a0..86dcca4a447 100644 --- a/app/views/notify/pipeline_failed_email.html.haml +++ b/app/views/notify/pipeline_failed_email.html.haml @@ -107,36 +107,5 @@ - else %td{ style: "font-family:'Menlo','Liberation Mono','Consolas','DejaVu Sans Mono','Ubuntu Mono','Courier New','andale mono','lucida console',monospace;font-size:14px;line-height:1.4;vertical-align:baseline;padding:0 5px;" } API -- failed = @pipeline.statuses.latest.failed -%tr - %td{ colspan: 2, style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;color:#333333;font-size:15px;font-weight:300;line-height:1.4;padding:15px 5px;text-align:center;" } - had - = failed.size - failed - #{'build'.pluralize(failed.size)}. -%tr.table-warning - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;border:1px solid #ededed;border-bottom:0;border-radius:3px 3px 0 0;overflow:hidden;background-color:#fdf4f6;color:#d22852;font-size:14px;line-height:1.4;text-align:center;padding:8px 15px;" } - Logs may contain sensitive data. Please consider before forwarding this email. -%tr.section - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:0 15px;border:1px solid #ededed;border-radius:3px;overflow:hidden;border-top:0;border-radius:0 0 3px 3px;" } - %table.builds{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;border-collapse:collapse;" } - %tbody - - failed.each do |build| - %tr.build-state - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:20px 0;color:#8c8c8c;font-weight:500;font-size:15px;" } - %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } - %tbody - %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;color:#d22f57;font-weight:500;font-size:15px;vertical-align:middle;padding-right:5px;line-height:10px" } - %img{ alt: "✖", height: "10", src: image_url('mailers/ci_pipeline_notif_v1/icon-x-red.gif'), style: "display:block;", width: "10" }/ - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;color:#8c8c8c;font-weight:500;font-size:15px;vertical-align:middle;" } - = build.stage - %td{ align: "right", style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:20px 0;color:#8c8c8c;font-weight:500;font-size:15px;" } - = render "notify/links/#{build.to_partial_path}", pipeline: @pipeline, build: build - %tr.build-log - - if build.has_trace? - %td{ colspan: "2", style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:0 0 15px;" } - %pre{ style: "font-family:Monaco,'Lucida Console','Courier New',Courier,monospace;background-color:#fafafa;border-radius:3px;overflow:hidden;white-space:pre-wrap;word-break:break-all;font-size:13px;line-height:1.4;padding:12px;color:#333333;margin:0;" } - = build.trace.html(last_lines: 10).html_safe - - else - %td{ colspan: "2" } + += render 'notify/failed_builds', pipeline: @pipeline, failed: @pipeline.statuses.latest.failed diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 9f79feb4ddd..0a1ee648d97 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -1,5 +1,6 @@ -- breadcrumb_title "Edit Profile" +- breadcrumb_title s_("Profiles|Edit Profile") - @content_class = "limit-container-width" unless fluid_layout +- gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host = bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit' }, authenticity_token: true do |f| = form_errors(@user) @@ -7,34 +8,36 @@ .row .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 - Public Avatar + = s_("Profiles|Public Avatar") %p - if @user.avatar? - You can change your avatar here - if gravatar_enabled? - or remove the current avatar to revert to #{link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host} + = s_("Profiles|You can change your avatar here or remove the current avatar to revert to %{gravatar_link}").html_safe % { gravatar_link: gravatar_link } + - else + = s_("Profiles|You can change your avatar here") - else - You can upload an avatar here - if gravatar_enabled? - or change it at #{link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host} + = s_("Profiles|You can upload your avatar here or change it at %{gravatar_link}").html_safe % { gravatar_link: gravatar_link } + - else + = s_("Profiles|You can upload your avatar here") .col-lg-8 .clearfix.avatar-image.append-bottom-default = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do = image_tag avatar_icon_for_user(@user, 160), alt: '', class: 'avatar s160' - %h5.prepend-top-0= _("Upload new avatar") + %h5.prepend-top-0= s_("Profiles|Upload new avatar") .prepend-top-5.append-bottom-10 - %button.btn.js-choose-user-avatar-button{ type: 'button' }= _("Choose file...") - %span.avatar-file-name.prepend-left-default.js-avatar-filename= _("No file chosen") + %button.btn.js-choose-user-avatar-button{ type: 'button' }= s_("Profiles|Choose file...") + %span.avatar-file-name.prepend-left-default.js-avatar-filename= s_("Profiles|No file chosen") = f.file_field_without_bootstrap :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*' - .form-text.text-muted= _("The maximum file size allowed is 200KB.") + .form-text.text-muted= s_("Profiles|The maximum file size allowed is 200KB.") - if @user.avatar? %hr - = link_to _('Remove avatar'), profile_avatar_path, data: { confirm: _('Avatar will be removed. Are you sure?') }, method: :delete, class: 'btn btn-danger btn-inverted' + = link_to s_("Profiles|Remove avatar"), profile_avatar_path, data: { confirm: s_("Profiles|Avatar will be removed. Are you sure?") }, method: :delete, class: 'btn btn-danger btn-inverted' %hr .row .col-lg-4.profile-settings-sidebar - %h4.prepend-top-0= s_("User|Current status") + %h4.prepend-top-0= s_("Profiles|Current status") %p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.") .col-lg-8 = f.fields_for :status, @user.status do |status_form| @@ -66,62 +69,66 @@ .row .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 - Main settings + = s_("Profiles|Main settings") %p - This information will appear on your profile. + = s_("Profiles|This information will appear on your profile.") - if current_user.ldap_user? - Some options are unavailable for LDAP accounts + = s_("Profiles|Some options are unavailable for LDAP accounts") .col-lg-8 .row - if @user.read_only_attribute?(:name) = f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9' }, - help: "Your name was automatically set based on your #{ attribute_provider_label(:name) } account, so people you know can recognize you." + help: s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you.") % { provider_label: attribute_provider_label(:name) } - else = f.text_field :name, label: 'Full name', required: true, wrapper: { class: 'col-md-9' }, help: "Enter your name, so people you know can recognize you." = f.text_field :id, readonly: true, label: 'User ID', wrapper: { class: 'col-md-3' } - if @user.read_only_attribute?(:email) - = f.text_field :email, required: true, readonly: true, help: "Your email address was automatically set based on your #{ attribute_provider_label(:email) } account." + = f.text_field :email, required: true, readonly: true, help: s_("Profiles|Your email address was automatically set based on your %{provider_label} account.") % { provider_label: attribute_provider_label(:email) } - else = f.text_field :email, required: true, value: (@user.email unless @user.temp_oauth_email?), help: user_email_help_text(@user) = f.select :public_email, options_for_select(@user.all_emails, selected: @user.public_email), - { help: 'This email will be displayed on your public profile.', include_blank: 'Do not show on profile' }, + { help: s_("Profiles|This email will be displayed on your public profile."), include_blank: s_("Profiles|Do not show on profile") }, control_class: 'select2' = f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] }, - { help: 'This feature is experimental and translations are not complete yet.' }, + { help: s_("Profiles|This feature is experimental and translations are not complete yet.") }, control_class: 'select2' = f.text_field :skype = f.text_field :linkedin = f.text_field :twitter - = f.text_field :website_url, label: 'Website' + = f.text_field :website_url, label: s_("Profiles|Website") - if @user.read_only_attribute?(:location) - = f.text_field :location, readonly: true, help: "Your location was automatically set based on your #{ attribute_provider_label(:location) } account." + = f.text_field :location, readonly: true, help: s_("Profiles|Your location was automatically set based on your %{provider_label} account.") % { provider_label: attribute_provider_label(:location) } - else = f.text_field :location = f.text_field :organization - = f.text_area :bio, rows: 4, maxlength: 250, help: 'Tell us about yourself in fewer than 250 characters.' + = f.text_area :bio, rows: 4, maxlength: 250, help: s_("Profiles|Tell us about yourself in fewer than 250 characters.") %hr - %h5 Private profile + %h5= ("Private profile") - private_profile_label = capture do - Don't display activity-related personal information on your profile + = s_("Profiles|Don't display activity-related personal information on your profiles") = link_to icon('question-circle'), help_page_path('user/profile/index.md', anchor: 'private-profile') = f.check_box :private_profile, label: private_profile_label + %h5= s_("Profiles|Private contributions") + = f.check_box :include_private_contributions, label: 'Include private contributions on my profile' + .help-block + = s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information.") .prepend-top-default.append-bottom-default - = f.submit 'Update profile settings', class: 'btn btn-success' - = link_to 'Cancel', user_path(current_user), class: 'btn btn-cancel' + = f.submit s_("Profiles|Update profile settings"), class: 'btn btn-success' + = link_to _("Cancel"), user_path(current_user), class: 'btn btn-cancel' .modal.modal-profile-crop .modal-dialog .modal-content .modal-header %h4.modal-title - Position and size your new avatar - %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } + = s_("Profiles|Position and size your new avatar") + %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _("Close") } %span{ "aria-hidden": true } × .modal-body .profile-crop-image-container - %img.modal-profile-crop-image{ alt: 'Avatar cropper' } + %img.modal-profile-crop-image{ alt: s_("Profiles|Avatar cropper") } .crop-controls .btn-group %button.btn.btn-primary{ data: { method: 'zoom', option: '0.1' } } @@ -130,4 +137,4 @@ %span.fa.fa-search-minus .modal-footer %button.btn.btn-primary.js-upload-user-avatar{ type: 'button' } - Set new profile picture + = s_("Profiles|Set new profile picture") diff --git a/app/views/projects/_flash_messages.html.haml b/app/views/projects/_flash_messages.html.haml index 0175b519867..7a5fff96676 100644 --- a/app/views/projects/_flash_messages.html.haml +++ b/app/views/projects/_flash_messages.html.haml @@ -5,3 +5,4 @@ - if current_user && can?(current_user, :download_code, project) = render 'shared/no_ssh' = render 'shared/no_password' + = render 'shared/auto_devops_implicitly_enabled_banner', project: project diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml index ad8c7911fad..3b6090211c0 100644 --- a/app/views/projects/_new_project_fields.html.haml +++ b/app/views/projects/_new_project_fields.html.haml @@ -3,10 +3,13 @@ .row{ id: project_name_id } = f.hidden_field :ci_cd_only, value: ci_cd_only + .form-group.project-name.col-sm-12 + = f.label :name, class: 'label-bold' do + %span= _("Project name") + = f.text_field :name, placeholder: "My awesome project", class: "form-control input-lg", autofocus: true, required: true .form-group.project-path.col-sm-6 = f.label :namespace_id, class: 'label-bold' do - %span - Project path + %span= s_("Project URL") .input-group - if current_user.can_select_namespace? .input-group-prepend.has-tooltip{ title: root_url } @@ -27,13 +30,12 @@ = f.hidden_field :namespace_id, value: current_user.namespace_id .form-group.project-path.col-sm-6 = f.label :path, class: 'label-bold' do - %span - Project name - = f.text_field :path, placeholder: "my-awesome-project", class: "form-control", tabindex: 2, autofocus: true, required: true + %span= _("Project slug") + = f.text_field :path, placeholder: "my-awesome-project", class: "form-control", tabindex: 2, required: true - if current_user.can_create_group? .form-text.text-muted Want to house several dependent projects under the same namespace? - = link_to "Create a group", new_group_path + = link_to "Create a group.", new_group_path .form-group = f.label :description, class: 'label-bold' do diff --git a/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml b/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml index 28c5be6ebf3..5be7cc7f25a 100644 --- a/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml +++ b/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml @@ -1,9 +1,9 @@ -- if viewer.valid? +- if viewer.valid?(@project, @commit.sha) = icon('check fw') This GitLab CI configuration is valid. - else = icon('warning fw') This GitLab CI configuration is invalid: - = viewer.validation_message + = viewer.validation_message(@project, @commit.sha) = link_to 'Learn more', help_page_path('ci/yaml/README') diff --git a/app/views/projects/clusters/_banner.html.haml b/app/views/projects/clusters/_banner.html.haml index f18caa3f4ac..73cfea0ef92 100644 --- a/app/views/projects/clusters/_banner.html.haml +++ b/app/views/projects/clusters/_banner.html.haml @@ -1,14 +1,15 @@ -%h4= s_('ClusterIntegration|Kubernetes cluster integration') +.hidden.js-cluster-error.bs-callout.bs-callout-danger{ role: 'alert' } + = s_('ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine') + %p.js-error-reason -.settings-content - .hidden.js-cluster-error.alert.alert-danger.alert-block.append-bottom-10{ role: 'alert' } - = s_('ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine') - %p.js-error-reason +.hidden.js-cluster-creating.bs-callout.bs-callout-info{ role: 'alert' } + = s_('ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine...') - .hidden.js-cluster-creating.alert.alert-info.alert-block.append-bottom-10{ role: 'alert' } - = s_('ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine...') +.hidden.js-cluster-success.bs-callout.bs-callout-success{ role: 'alert' } + = s_("ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details") - .hidden.js-cluster-success.alert.alert-success.alert-block.append-bottom-10{ role: 'alert' } - = s_("ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details") - - %p= s_('ClusterIntegration|Control how your Kubernetes cluster integrates with GitLab') +- if show_cluster_security_warning? + .js-cluster-security-warning.alert.alert-block.alert-dismissable.bs-callout.bs-callout-warning + %button.close{ type: "button", data: { feature_id: UserCalloutsHelper::CLUSTER_SECURITY_WARNING, dismiss_endpoint: user_callouts_path } } × + = s_("ClusterIntegration|The default cluster configuration grants access to many functionalities needed to successfully build and deploy a containerised application.") + = link_to s_("More information"), help_page_path('user/project/clusters/index.md', anchor: 'security-implications') diff --git a/app/views/projects/clusters/_integration_form.html.haml b/app/views/projects/clusters/_integration_form.html.haml index b46b45fea49..d0a553e3414 100644 --- a/app/views/projects/clusters/_integration_form.html.haml +++ b/app/views/projects/clusters/_integration_form.html.haml @@ -2,14 +2,6 @@ = form_errors(@cluster) .form-group %h5= s_('ClusterIntegration|Integration status') - %p - - if @cluster.enabled? - - if can?(current_user, :update_cluster, @cluster) - = s_('ClusterIntegration|Kubernetes cluster integration is enabled for this project. Disabling this integration will not affect your Kubernetes cluster, it will only temporarily turn off GitLab\'s connection to it.') - - else - = s_('ClusterIntegration|Kubernetes cluster integration is enabled for this project.') - - else - = s_('ClusterIntegration|Kubernetes cluster integration is disabled for this project.') %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)}", @@ -19,14 +11,13 @@ %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.') - if has_multiple_clusters?(@project) .form-group %h5= s_('ClusterIntegration|Environment scope') - %p - = s_("ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster.") - = link_to s_("ClusterIntegration|Learn more about environments"), help_page_path('ci/environments') - = field.text_field :environment_scope, class: 'form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Environment scope') + = field.text_field :environment_scope, class: 'col-md-6 form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Environment scope') + .form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster.") - if can?(current_user, :update_cluster, @cluster) .form-group @@ -38,8 +29,3 @@ %code * is the default environment scope for this cluster. This means that all jobs, regardless of their environment, will use this cluster. = link_to 'More information', ('https://docs.gitlab.com/ee/user/project/clusters/#setting-the-environment-scope') - - %h5= s_('ClusterIntegration|Security') - %p - = s_("ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application.") - = link_to s_("ClusterIntegration|Learn more about security configuration"), help_page_path('user/project/clusters/index.md', anchor: 'security-implications') diff --git a/app/views/projects/clusters/show.html.haml b/app/views/projects/clusters/show.html.haml index 08d2deff6f8..eddd3613c5f 100644 --- a/app/views/projects/clusters/show.html.haml +++ b/app/views/projects/clusters/show.html.haml @@ -23,7 +23,8 @@ .js-cluster-application-notice .flash-container - %section.settings.no-animate.expanded#cluster-integration + %section#cluster-integration + %h4= @cluster.name = render 'banner' = render 'integration_form' diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml index a966bfb2dd9..996c7b1b960 100644 --- a/app/views/projects/find_file/show.html.haml +++ b/app/views/projects/find_file/show.html.haml @@ -13,6 +13,6 @@ .tree-content-holder .table-holder - %table.table.files-slider{ class: "table_#{@hex_path} tree-table table-striped" } + %table.table.files-slider{ class: "table_#{@hex_path} tree-table" } %tbody = spinner nil, true diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index dfac62e7985..1bfd8a85f0f 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -22,6 +22,7 @@ %span.input-group-append %button.btn.btn-default{ type: "submit", "aria-label" => _('Submit search') } = icon("search") + = render 'shared/labels/sort_dropdown' .labels-container.prepend-top-10 - if can_admin_label diff --git a/app/views/projects/project_members/_new_shared_group.html.haml b/app/views/projects/project_members/_new_project_group.html.haml index d7227c32833..7de24ec5ad2 100644 --- a/app/views/projects/project_members/_new_shared_group.html.haml +++ b/app/views/projects/project_members/_new_project_group.html.haml @@ -2,19 +2,19 @@ .col-sm-12 = form_tag project_group_links_path(@project), class: 'js-requires-input', method: :post do .form-group - = label_tag :link_group_id, "Select a group to share with", class: "label-bold" + = label_tag :link_group_id, _("Select a group to invite"), class: "label-bold" = groups_select_tag(:link_group_id, data: { skip_groups: @skip_groups }, class: "input-clamp", required: true) .form-group - = label_tag :link_group_access, "Max access level", class: "label-bold" + = label_tag :link_group_access, _("Max access level"), class: "label-bold" .select-wrapper = select_tag :link_group_access, options_for_select(ProjectGroupLink.access_options, ProjectGroupLink.default_access), class: "form-control select-control" = icon('chevron-down') .form-text.text-muted.append-bottom-10 - = link_to "Read more", help_page_path("user/permissions"), class: "vlink" + = link_to _("Read more"), help_page_path("user/permissions"), class: "vlink" about role permissions .form-group - = label_tag :expires_at, 'Access expiration date', class: 'label-bold' + = label_tag :expires_at, _('Access expiration date'), class: 'label-bold' .clearable-input - = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date-groups', placeholder: 'Expiration date', id: 'expires_at_groups' + = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date-groups', placeholder: _('Expiration date'), id: 'expires_at_groups' %i.clear-icon.js-clear-input - = submit_tag "Share", class: "btn btn-create" + = submit_tag _("Invite"), class: "btn btn-create" diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 9716322f8a1..14ed3345765 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -6,9 +6,9 @@ Project members - if can?(current_user, :admin_project_member, @project) %p - You can add a new member to + You can invite a new member to %strong= @project.name - or share it with another group. + or invite another group. - else %p Members can be added by project @@ -19,16 +19,16 @@ - if can?(current_user, :admin_project_member, @project) %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } %li.nav-tab{ role: 'presentation' } - %a.nav-link.active{ href: '#add-member-pane', id: 'add-member-tab', data: { toggle: 'tab' }, role: 'tab' } Add member + %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' } Invite member - if @project.allowed_to_share_with_group? %li.nav-tab{ role: 'presentation' } - %a.nav-link{ href: '#share-with-group-pane', id: 'share-with-group-tab', data: { toggle: 'tab' }, role: 'tab' } Share with group + %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab' }, role: 'tab' } Invite group .tab-content.gitlab-tab-content - .tab-pane.active{ id: 'add-member-pane', role: 'tabpanel' } - = render 'projects/project_members/new_project_member', tab_title: 'Add member' - .tab-pane{ id: 'share-with-group-pane', role: 'tabpanel' } - = render 'projects/project_members/new_shared_group', tab_title: 'Share with group' + .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' } + = render 'projects/project_members/new_project_member', tab_title: 'Invite member' + .tab-pane{ id: 'invite-group-pane', role: 'tabpanel' } + = render 'projects/project_members/new_project_group', tab_title: 'Invite group' = render 'shared/members/requests', membership_source: @project, requesters: @requesters .clearfix diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml index 7fb80450161..35872d70db4 100644 --- a/app/views/projects/wikis/_form.html.haml +++ b/app/views/projects/wikis/_form.html.haml @@ -7,7 +7,7 @@ = form_for [@project.namespace.becomes(Namespace), @project, @page], method: @page.persisted? ? :put : :post, html: { class: 'wiki-form common-note-form prepend-top-default js-quick-submit' }, - data: { markdown_version: markdown_version } do |f| + data: { markdown_version: markdown_version, uploads_path: uploads_path } do |f| = form_errors(@page) - if @page.persisted? diff --git a/app/views/projects/wikis/edit.html.haml b/app/views/projects/wikis/edit.html.haml index 71359708022..e12b8f2858c 100644 --- a/app/views/projects/wikis/edit.html.haml +++ b/app/views/projects/wikis/edit.html.haml @@ -28,21 +28,8 @@ = link_to project_wiki_history_path(@project, @page), class: "btn" do = s_("Wiki|Page history") - if can?(current_user, :admin_wiki, @project) - %button.btn.btn-danger{ data: { toggle: 'modal', - target: '#delete-wiki-modal', - delete_wiki_url: project_wiki_path(@project, @page), - page_title: @page.title.capitalize }, - id: 'delete-wiki-button', - type: 'button' } - = _('Delete') + #delete-wiki-modal-wrapper{ data: { delete_wiki_url: project_wiki_path(@project, @page), page_title: @page.title.capitalize } } -= render 'form' += render 'form', uploads_path: wiki_attachment_upload_url = render 'sidebar' - -#delete-wiki-modal.modal.fade - -- content_for :scripts_body do - -# haml-lint:disable InlineJavaScript - :javascript - window.uploads_path = "#{wiki_attachment_upload_url}"; diff --git a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml new file mode 100644 index 00000000000..6c4607b2f16 --- /dev/null +++ b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml @@ -0,0 +1,9 @@ +- if show_auto_devops_implicitly_enabled_banner?(project) + .auto-devops-implicitly-enabled-banner.alert.alert-warning + - more_information_link = link_to _('More information'), 'https://docs.gitlab.com/ee/topics/autodevops/', class: 'alert-link' + - auto_devops_message = s_("AutoDevOps|The Auto DevOps pipeline has been enabled and will be used if no alternative CI configuration file is found. %{more_information_link}") % { more_information_link: more_information_link } + = auto_devops_message.html_safe + .alert-link-group + = link_to _('Settings'), project_settings_ci_cd_path(project), class: 'alert-link' + | + = link_to _('Dismiss'), '#', class: 'hide-auto-devops-implicitly-enabled-banner alert-link', data: { project_id: project.id } diff --git a/app/views/shared/_ping_consent.html.haml b/app/views/shared/_ping_consent.html.haml new file mode 100644 index 00000000000..f8eb2b2833b --- /dev/null +++ b/app/views/shared/_ping_consent.html.haml @@ -0,0 +1,12 @@ +- if session[:ask_for_usage_stats_consent] + .ping-consent-message.alert.alert-warning.flex-alert + - settings_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="alert-link">'.html_safe % { url: admin_application_settings_path(anchor: 'js-usage-settings') } + - info_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="alert-link">'.html_safe % { url: help_page_path('user/admin_area/settings/usage_statistics.md') } + .alert-message + = s_('To help improve GitLab, we would like to periodically collect usage information. This can be changed at any time in %{settings_link_start}Settings%{link_end}. %{info_link_start}More Information%{link_end}').html_safe % { settings_link_start: settings_link_start, info_link_start: info_link_start, link_end: '</a>'.html_safe } + .alert-link-group + - send_usage_data_path = admin_application_settings_path(application_setting: { version_check_enabled: 1, usage_ping_enabled: 1 }) + - not_now_path = admin_application_settings_path(application_setting: { version_check_enabled: 0, usage_ping_enabled: 0 }) + = link_to _("Send usage data"), send_usage_data_path, 'data-url' => admin_application_settings_path, method: :put, 'data-check-enabled': true, 'data-ping-enabled': true, class: 'alert-link js-usage-consent-action' + | + = link_to _('Not now'), not_now_path, 'data-url' => admin_application_settings_path, method: :put, 'data-check-enabled': false, 'data-ping-enabled': false, class: 'hide-ping-consent-message alert-link js-usage-consent-action' diff --git a/app/views/shared/groups/_empty_state.html.haml b/app/views/shared/groups/_empty_state.html.haml index a9c78547eae..c35f6f5a3c1 100644 --- a/app/views/shared/groups/_empty_state.html.haml +++ b/app/views/shared/groups/_empty_state.html.haml @@ -1,7 +1,8 @@ -.groups-empty-state.qa-groups-empty-state - = custom_icon("icon_empty_groups") +.group-empty-state.row.align-items-center.justify-content-center.qa-groups-empty-state + .icon.text-center.order-md-2 + = custom_icon("icon_empty_groups") - .text-content + .text-content.m-0.order-md-1 %h4= s_("GroupsEmptyState|A group is a collection of several projects.") %p= s_("GroupsEmptyState|If you organize your projects under a group, it works like a folder.") %p= s_("GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group.") diff --git a/app/views/shared/groups/_search_form.html.haml b/app/views/shared/groups/_search_form.html.haml index 3f91263089a..67e1cd0d67b 100644 --- a/app/views/shared/groups/_search_form.html.haml +++ b/app/views/shared/groups/_search_form.html.haml @@ -1,2 +1,2 @@ -= form_tag request.path, method: :get, class: 'group-filter-form append-right-10', id: 'group-filter-form' do |f| - = search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Filter by name...'), class: 'group-filter-form-field form-control input-short js-groups-list-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2" += form_tag request.path, method: :get, class: "group-filter-form js-group-filter-form", id: 'group-filter-form' do |f| + = search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Search by name'), class: 'group-filter-form-field form-control js-groups-list-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2" diff --git a/app/views/shared/labels/_sort_dropdown.html.haml b/app/views/shared/labels/_sort_dropdown.html.haml new file mode 100644 index 00000000000..ff6e2947ffd --- /dev/null +++ b/app/views/shared/labels/_sort_dropdown.html.haml @@ -0,0 +1,9 @@ +- sort_title = label_sort_options_hash[@sort] || sort_title_name_desc +.dropdown.inline + %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown' } } + = sort_title + = icon('chevron-down') + %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-sort + %li + - label_sort_options_hash.each do |value, title| + = sortable_item(title, page_filter_path(sort: value, label: true), sort_title) diff --git a/app/views/users/calendar_activities.html.haml b/app/views/users/calendar_activities.html.haml index 2d4656e8608..938cb579e9f 100644 --- a/app/views/users/calendar_activities.html.haml +++ b/app/views/users/calendar_activities.html.haml @@ -1,6 +1,5 @@ %h4.prepend-top-20 - Contributions for - %strong= @calendar_date.to_s(:medium) + = _("Contributions for <strong>%{calendar_date}</strong>").html_safe % { calendar_date: @calendar_date.to_s(:medium) } - if @events.any? %ul.bordered-list @@ -9,25 +8,28 @@ %span.light %i.fa.fa-clock-o = event.created_at.strftime('%-I:%M%P') - - if event.push? - #{event.action_name} #{event.ref_type} + - if event.visible_to_user?(current_user) + - if event.push? + #{event.action_name} #{event.ref_type} + %strong + - commits_path = project_commits_path(event.project, event.ref_name) + = link_to_if event.project.repository.branch_exists?(event.ref_name), event.ref_name, commits_path + - else + = event_action_name(event) + %strong + - if event.note? + = link_to event.note_target.to_reference, event_note_target_url(event), class: 'has-tooltip', title: event.target_title + - elsif event.target + = link_to event.target.to_reference, [event.project.namespace.becomes(Namespace), event.project, event.target], class: 'has-tooltip', title: event.target_title + + at %strong - - commits_path = project_commits_path(event.project, event.ref_name) - = link_to_if event.project.repository.branch_exists?(event.ref_name), event.ref_name, commits_path + - if event.project + = link_to_project(event.project) + - else + = event.project_name - else - = event_action_name(event) - %strong - - if event.note? - = link_to event.note_target.to_reference, event_note_target_url(event), class: 'has-tooltip', title: event.target_title - - elsif event.target - = link_to event.target.to_reference, [event.project.namespace.becomes(Namespace), event.project, event.target], class: 'has-tooltip', title: event.target_title - - at - %strong - - if event.project - = link_to_project event.project - - else - = event.project_name + made a private contribution - else %p - No contributions found for #{@calendar_date.to_s(:medium)} + = _('No contributions were found') diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index f95df7ecf03..1eeb972cee9 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -1,4 +1,6 @@ --- +- auto_devops:auto_devops_disable + - cronjob:admin_email - cronjob:expire_build_artifacts - cronjob:gitlab_usage_ping @@ -85,6 +87,7 @@ - authorized_projects - background_migration - create_gpg_signature +- delete_container_repository - delete_merged_branches - delete_user - email_receiver diff --git a/app/workers/auto_devops/disable_worker.rb b/app/workers/auto_devops/disable_worker.rb new file mode 100644 index 00000000000..73ddc591505 --- /dev/null +++ b/app/workers/auto_devops/disable_worker.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module AutoDevops + class DisableWorker + include ApplicationWorker + include AutoDevopsQueue + + def perform(pipeline_id) + pipeline = Ci::Pipeline.find(pipeline_id) + project = pipeline.project + + send_notification_email(pipeline, project) if disable_service(project).execute + end + + private + + def disable_service(project) + Projects::AutoDevops::DisableService.new(project) + end + + def send_notification_email(pipeline, project) + recipients = email_receivers_for(pipeline, project) + + return unless recipients.any? + + NotificationService.new.autodevops_disabled(pipeline, recipients) + end + + def email_receivers_for(pipeline, project) + recipients = [pipeline.user&.email] + recipients << project.owner.email unless project.group + recipients.uniq.compact + end + end +end diff --git a/app/workers/concerns/auto_devops_queue.rb b/app/workers/concerns/auto_devops_queue.rb new file mode 100644 index 00000000000..aba928ccaab --- /dev/null +++ b/app/workers/concerns/auto_devops_queue.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true +# +module AutoDevopsQueue + extend ActiveSupport::Concern + + included do + queue_namespace :auto_devops + end +end diff --git a/app/workers/delete_container_repository_worker.rb b/app/workers/delete_container_repository_worker.rb new file mode 100644 index 00000000000..b703530d3a0 --- /dev/null +++ b/app/workers/delete_container_repository_worker.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class DeleteContainerRepositoryWorker + include ApplicationWorker + include ExclusiveLeaseGuard + + LEASE_TIMEOUT = 1.hour + + attr_reader :container_repository + + def perform(current_user_id, container_repository_id) + current_user = User.find_by(id: current_user_id) + @container_repository = ContainerRepository.find_by(id: container_repository_id) + project = container_repository&.project + + return unless current_user && container_repository && project + + # If a user accidentally attempts to delete the same container registry in quick succession, + # this can lead to orphaned tags. + try_obtain_lease do + Projects::ContainerRepository::DestroyService.new(project, current_user).execute(container_repository) + end + end + + # For ExclusiveLeaseGuard concern + def lease_key + @lease_key ||= "container_repository:delete:#{container_repository.id}" + end + + # For ExclusiveLeaseGuard concern + def lease_timeout + LEASE_TIMEOUT + end +end diff --git a/app/workers/new_merge_request_worker.rb b/app/workers/new_merge_request_worker.rb index 5d8b8904502..62f9d9b6f57 100644 --- a/app/workers/new_merge_request_worker.rb +++ b/app/workers/new_merge_request_worker.rb @@ -9,6 +9,8 @@ class NewMergeRequestWorker EventCreateService.new.open_mr(issuable, user) NotificationService.new.new_merge_request(issuable, user) + + issuable.diffs.write_cache issuable.create_cross_references!(user) end diff --git a/app/workers/project_service_worker.rb b/app/workers/project_service_worker.rb index a0bc9288cf0..25567cec08b 100644 --- a/app/workers/project_service_worker.rb +++ b/app/workers/project_service_worker.rb @@ -7,6 +7,10 @@ class ProjectServiceWorker def perform(hook_id, data) data = data.with_indifferent_access - Service.find(hook_id).execute(data) + service = Service.find(hook_id) + service.execute(data) + rescue => error + service_class = service&.class&.name || "Not Found" + logger.error class: self.class.name, service_class: service_class, message: error.message end end |