diff options
author | Marin Jankovski <maxlazio@gmail.com> | 2019-07-03 12:55:56 +0300 |
---|---|---|
committer | Marin Jankovski <maxlazio@gmail.com> | 2019-07-03 12:55:56 +0300 |
commit | c20c9e2940b0f94547246d05b7b526f0b1571027 (patch) | |
tree | c548960a37ab7447ff542e0844e838f973c118fb /app | |
parent | 49d689fb3c7781c861f995aaafef4b224581020b (diff) | |
parent | 2ca9bda400c0ed647c3ef342dcc0aa56c558cebe (diff) |
Merge branch 'master' of gitlab.com:gitlab-org/gitlab-ce
Diffstat (limited to 'app')
198 files changed, 2148 insertions, 895 deletions
diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js index 45b9e57f9ab..c6122fbc686 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -1,6 +1,7 @@ +import $ from 'jquery'; import Sortable from 'sortablejs'; import Vue from 'vue'; -import { n__ } from '~/locale'; +import { n__, s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import Tooltip from '~/vue_shared/directives/tooltip'; import AccessorUtilities from '../../lib/utils/accessor'; @@ -53,12 +54,19 @@ export default Vue.extend({ const { issuesSize } = this.list; return `${n__('%d issue', '%d issues', issuesSize)}`; }, + caretTooltip() { + return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand'); + }, isNewIssueShown() { return ( this.list.type === 'backlog' || (!this.disabled && this.list.type !== 'closed' && this.list.type !== 'blank') ); }, + uniqueKey() { + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings + return `boards.${this.boardId}.${this.list.type}.${this.list.id}`; + }, }, watch: { filter: { @@ -72,31 +80,34 @@ export default Vue.extend({ }, }, mounted() { - this.sortableOptions = getBoardSortableDefaultOptions({ + const instance = this; + + const sortableOptions = getBoardSortableDefaultOptions({ disabled: this.disabled, group: 'boards', draggable: '.is-draggable', handle: '.js-board-handle', - onEnd: e => { + onEnd(e) { sortableEnd(); + const sortable = this; + if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) { - const order = this.sortable.toArray(); + const order = sortable.toArray(); const list = boardsStore.findList('id', parseInt(e.item.dataset.id, 10)); - this.$nextTick(() => { + instance.$nextTick(() => { boardsStore.moveList(list, order); }); } }, }); - this.sortable = Sortable.create(this.$el.parentNode, this.sortableOptions); + Sortable.create(this.$el.parentNode, sortableOptions); }, created() { if (this.list.isExpandable && AccessorUtilities.isLocalStorageAccessSafe()) { - const isCollapsed = - localStorage.getItem(`boards.${this.boardId}.${this.list.type}.expanded`) === 'false'; + const isCollapsed = localStorage.getItem(`${this.uniqueKey}.expanded`) === 'false'; this.list.isExpanded = !isCollapsed; } @@ -105,16 +116,17 @@ export default Vue.extend({ showNewIssueForm() { this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm; }, - toggleExpanded(e) { - if (this.list.isExpandable && !e.target.classList.contains('js-no-trigger-collapse')) { + toggleExpanded() { + if (this.list.isExpandable) { this.list.isExpanded = !this.list.isExpanded; if (AccessorUtilities.isLocalStorageAccessSafe()) { - localStorage.setItem( - `boards.${this.boardId}.${this.list.type}.expanded`, - this.list.isExpanded, - ); + localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded); } + + // When expanding/collapsing, the tooltip on the caret button sometimes stays open. + // Close all tooltips manually to prevent dangling tooltips. + $('.tooltip').tooltip('hide'); } }, }, diff --git a/app/assets/javascripts/boards/components/board_blank_state.vue b/app/assets/javascripts/boards/components/board_blank_state.vue index 1cbd31729cd..f58149c9f7b 100644 --- a/app/assets/javascripts/boards/components/board_blank_state.vue +++ b/app/assets/javascripts/boards/components/board_blank_state.vue @@ -1,4 +1,5 @@ <script> +import { __ } from '~/locale'; /* global ListLabel */ import Cookies from 'js-cookie'; import boardsStore from '../stores/boards_store'; @@ -7,8 +8,8 @@ export default { data() { return { predefinedLabels: [ - new ListLabel({ title: 'To Do', color: '#F0AD4E' }), - new ListLabel({ title: 'Doing', color: '#5CB85C' }), + new ListLabel({ title: __('To Do'), color: '#F0AD4E' }), + new ListLabel({ title: __('Doing'), color: '#5CB85C' }), ], }; }, @@ -58,7 +59,11 @@ export default { <template> <div class="board-blank-state p-3"> - <p>Add the following default lists to your Issue Board with one click:</p> + <p> + {{ + __('BoardBlankState|Add the following default lists to your Issue Board with one click:') + }} + </p> <ul class="list-unstyled board-blank-state-list"> <li v-for="(label, index) in predefinedLabels" :key="index"> <span @@ -70,18 +75,21 @@ export default { </li> </ul> <p> - Starting out with the default set of lists will get you right on the way to making the most of - your board. + {{ + __( + 'BoardBlankState|Starting out with the default set of lists will get you right on the way to making the most of your board.', + ) + }} </p> <button class="btn btn-success btn-inverted btn-block" type="button" @click.stop="addDefaultLists" > - Add default lists + {{ __('BoardBlankState|Add default lists') }} </button> <button class="btn btn-default btn-block" type="button" @click.stop="clearBlankState"> - Nevermind, I'll use my own + {{ __("BoardBlankState|Nevermind, I'll use my own") }} </button> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index b1a8b13f3ac..787ff110bf8 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -227,7 +227,7 @@ export default { :class="{ 'd-none': !list.isExpanded, 'd-flex flex-column': list.isExpanded }" class="board-list-component position-relative h-100" > - <div v-if="loading" class="board-list-loading text-center" aria-label="Loading issues"> + <div v-if="loading" class="board-list-loading text-center" :aria-label="__('Loading issues')"> <gl-loading-icon /> </div> <board-new-issue @@ -257,7 +257,7 @@ export default { /> <li v-if="showCount" class="board-list-count text-center" data-issue-id="-1"> <gl-loading-icon v-show="list.loadingMore" label="Loading more issues" /> - <span v-if="list.issues.length === list.issuesSize"> Showing all issues </span> + <span v-if="list.issues.length === list.issuesSize">{{ __('Showing all issues') }}</span> <span v-else> Showing {{ list.issues.length }} of {{ list.issuesSize }} issues </span> </li> </ul> diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index cc6af8e88cd..4180023b7db 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -102,9 +102,9 @@ export default { <div class="board-card position-relative p-3 rounded"> <form @submit="submit($event)"> <div v-if="error" class="flash-container"> - <div class="flash-alert">An error occurred. Please try again.</div> + <div class="flash-alert">{{ __('An error occurred. Please try again.') }}</div> </div> - <label :for="list.id + '-title'" class="label-bold"> Title </label> + <label :for="list.id + '-title'" class="label-bold">{{ __('Title') }}</label> <input :id="list.id + '-title'" ref="input" @@ -122,12 +122,11 @@ export default { class="float-left" variant="success" type="submit" + >{{ __('Submit issue') }}</gl-button > - Submit issue - </gl-button> - <gl-button class="float-right" type="button" variant="default" @click="cancel"> - Cancel - </gl-button> + <gl-button class="float-right" type="button" variant="default" @click="cancel">{{ + __('Cancel') + }}</gl-button> </div> </form> </div> diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index a8516f178fc..7f554c99669 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -124,7 +124,7 @@ export default { return `${this.rootPath}${assignee.username}`; }, avatarUrlTitle(assignee) { - return `Avatar for ${assignee.name}`; + return sprintf(__(`Avatar for %{assigneeName}`), { assigneeName: assignee.name }); }, showLabel(label) { if (!label.id) return false; @@ -160,9 +160,10 @@ export default { :title="__('Confidential')" class="confidential-icon append-right-4" :aria-label="__('Confidential')" - /><a :href="issue.path" :title="issue.title" class="js-no-trigger" @mousemove.stop>{{ - issue.title - }}</a> + /> + <a :href="issue.path" :title="issue.title" class="js-no-trigger" @mousemove.stop> + {{ issue.title }} + </a> </h4> </div> <div v-if="showLabelFooter" class="board-card-labels prepend-top-4 d-flex flex-wrap"> @@ -204,13 +205,13 @@ export default { placement="bottom" class="board-issue-path block-truncated bold" >{{ issueReferencePath }}</tooltip-on-truncate - >#{{ issue.iid }} + > + #{{ issue.iid }} </span> <span class="board-info-items prepend-top-8 d-inline-block"> - <issue-due-date v-if="issue.dueDate" :date="issue.dueDate" /><issue-time-estimate - v-if="issue.timeEstimate" - :estimate="issue.timeEstimate" - /><issue-card-weight + <issue-due-date v-if="issue.dueDate" :date="issue.dueDate" /> + <issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" /> + <issue-card-weight v-if="issue.weight" :weight="issue.weight" @click="filterByWeight(issue.weight)" @@ -230,7 +231,8 @@ export default { tooltip-placement="bottom" > <span class="js-assignee-tooltip"> - <span class="bold d-block">Assignee</span> {{ assignee.name }} + <span class="bold d-block">{{ __('Assignee') }}</span> + {{ assignee.name }} <span class="text-white-50">@{{ assignee.username }}</span> </span> </user-avatar-link> @@ -240,9 +242,8 @@ export default { :title="assigneeCounterTooltip" class="avatar-counter" data-placement="bottom" + >{{ assigneeCounterLabel }}</span > - {{ assigneeCounterLabel }} - </span> </div> </div> </div> diff --git a/app/assets/javascripts/boards/components/modal/empty_state.vue b/app/assets/javascripts/boards/components/modal/empty_state.vue index 091700de93f..66f59009714 100644 --- a/app/assets/javascripts/boards/components/modal/empty_state.vue +++ b/app/assets/javascripts/boards/components/modal/empty_state.vue @@ -1,4 +1,5 @@ <script> +import { __, sprintf } from '~/locale'; import ModalStore from '../../stores/modal_store'; import modalMixin from '../../mixins/modal_mixins'; @@ -20,19 +21,20 @@ export default { computed: { contents() { const obj = { - title: "You haven't added any issues to your project yet", - content: ` - An issue can be a bug, a todo or a feature request that needs to be - discussed in a project. Besides, issues are searchable and filterable. - `, + title: __("You haven't added any issues to your project yet"), + content: __( + 'An issue can be a bug, a todo or a feature request that needs to be discussed in a project. Besides, issues are searchable and filterable.', + ), }; if (this.activeTab === 'selected') { - obj.title = "You haven't selected any issues yet"; - obj.content = ` - Go back to <strong>Open issues</strong> and select some issues - to add to your board. - `; + obj.title = __("You haven't selected any issues yet"); + obj.content = sprintf( + __( + 'Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board.', + ), + { startTag: '<strong>', endTag: '</strong>' }, + ); } return obj; @@ -51,16 +53,16 @@ export default { <div class="text-content"> <h4>{{ contents.title }}</h4> <p v-html="contents.content"></p> - <a v-if="activeTab === 'all'" :href="newIssuePath" class="btn btn-success btn-inverted"> - New issue - </a> + <a v-if="activeTab === 'all'" :href="newIssuePath" class="btn btn-success btn-inverted">{{ + __('New issue') + }}</a> <button v-if="activeTab === 'selected'" class="btn btn-default" type="button" @click="changeTab('all')" > - Open issues + {{ __('Open issues') }} </button> </div> </div> diff --git a/app/assets/javascripts/boards/components/modal/footer.vue b/app/assets/javascripts/boards/components/modal/footer.vue index d4afd9d59da..a1d634c8f19 100644 --- a/app/assets/javascripts/boards/components/modal/footer.vue +++ b/app/assets/javascripts/boards/components/modal/footer.vue @@ -1,8 +1,7 @@ <script> import Flash from '../../../flash'; -import { __ } from '../../../locale'; +import { __, n__ } from '../../../locale'; import ListsDropdown from './lists_dropdown.vue'; -import { pluralize } from '../../../lib/utils/text_utility'; import ModalStore from '../../stores/modal_store'; import modalMixin from '../../mixins/modal_mixins'; import boardsStore from '../../stores/boards_store'; @@ -24,8 +23,8 @@ export default { }, submitText() { const count = ModalStore.selectedCount(); - - return `Add ${count > 0 ? count : ''} ${pluralize('issue', count)}`; + if (!count) return __('Add issues'); + return n__(`Add %d issue`, `Add %d issues`, count); }, }, methods: { @@ -68,11 +67,11 @@ export default { <button :disabled="submitDisabled" class="btn btn-success" type="button" @click="addIssues"> {{ submitText }} </button> - <span class="inline add-issues-footer-to-list"> to list </span> + <span class="inline add-issues-footer-to-list">{{ __('to list') }}</span> <lists-dropdown /> </div> <button class="btn btn-default float-right" type="button" @click="toggleModal(false)"> - Cancel + {{ __('Cancel') }} </button> </footer> </template> diff --git a/app/assets/javascripts/boards/components/modal/header.vue b/app/assets/javascripts/boards/components/modal/header.vue index 1cfa6d39362..7a696035dc8 100644 --- a/app/assets/javascripts/boards/components/modal/header.vue +++ b/app/assets/javascripts/boards/components/modal/header.vue @@ -1,4 +1,5 @@ <script> +import { __ } from '~/locale'; import ModalFilters from './filters'; import ModalTabs from './tabs.vue'; import ModalStore from '../../stores/modal_store'; @@ -30,10 +31,10 @@ export default { computed: { selectAllText() { if (ModalStore.selectedCount() !== this.issues.length || this.issues.length === 0) { - return 'Select all'; + return __('Select all'); } - return 'Deselect all'; + return __('Deselect all'); }, showSearch() { return this.activeTab === 'all' && !this.loading && this.issuesCount > 0; @@ -57,7 +58,7 @@ export default { type="button" class="close" data-dismiss="modal" - aria-label="Close" + :aria-label="__('Close')" @click="toggleModal(false)" > <span aria-hidden="true">×</span> diff --git a/app/assets/javascripts/boards/components/modal/list.vue b/app/assets/javascripts/boards/components/modal/list.vue index 28d2019af2f..1802b543687 100644 --- a/app/assets/javascripts/boards/components/modal/list.vue +++ b/app/assets/javascripts/boards/components/modal/list.vue @@ -123,7 +123,9 @@ export default { class="empty-state add-issues-empty-state-filter text-center" > <div class="svg-content"><img :src="emptyStateSvg" /></div> - <div class="text-content"><h4>There are no issues to show.</h4></div> + <div class="text-content"> + <h4>{{ __('There are no issues to show.') }}</h4> + </div> </div> <div v-for="(group, index) in groupedIssues" :key="index" class="add-issues-list-column"> <div v-for="issue in group" v-if="showIssue(issue)" :key="issue.id" class="board-card-parent"> diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index 8274647744f..a1cf1866faf 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -1,4 +1,5 @@ <script> +import { __ } from '~/locale'; import $ from 'jquery'; import _ from 'underscore'; import Icon from '~/vue_shared/components/icon.vue'; @@ -27,7 +28,7 @@ export default { }, computed: { selectedProjectName() { - return this.selectedProject.name || 'Select a project'; + return this.selectedProject.name || __('Select a project'); }, }, mounted() { @@ -81,7 +82,7 @@ export default { <template> <div> - <label class="label-bold prepend-top-10"> Project </label> + <label class="label-bold prepend-top-10">{{ __('Project') }}</label> <div ref="projectsDropdown" class="dropdown dropdown-projects"> <button class="dropdown-menu-toggle wide" @@ -92,9 +93,9 @@ export default { {{ selectedProjectName }} <icon name="chevron-down" /> </button> <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width"> - <div class="dropdown-title">Projects</div> + <div class="dropdown-title">{{ __('Projects') }}</div> <div class="dropdown-input"> - <input class="dropdown-input-field" type="search" placeholder="Search projects" /> + <input class="dropdown-input-field" type="search" :placeholder="__('Search projects')" /> <icon name="search" class="dropdown-input-search" data-hidden="true" /> </div> <div class="dropdown-content"></div> diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue index 4ab2b17301f..b84722244d1 100644 --- a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue +++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue @@ -76,7 +76,7 @@ export default Vue.extend({ <template> <div class="block list"> <button class="btn btn-default btn-block" type="button" @click="removeIssue"> - Remove from board + {{ __('Remove from board') }} </button> </div> </template> diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js index 636ca99952c..68ea28e68d9 100644 --- a/app/assets/javascripts/boards/mixins/sortable_default_options.js +++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js @@ -20,7 +20,7 @@ export function getBoardSortableDefaultOptions(obj) { 'ontouchstart' in window || (window.DocumentTouch && document instanceof DocumentTouch); const defaultSortOptions = Object.assign({}, sortableConfig, { - filter: '.board-delete, .btn', + filter: '.no-drag', delay: touchEnabled ? 100 : 0, scrollSensitivity: touchEnabled ? 60 : 100, scrollSpeed: 20, diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index a9d88f19146..cd553d0c4af 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -26,6 +26,12 @@ const TYPES = { isExpandable: false, isBlank: true, }, + default: { + // includes label, assignee, and milestone lists + isPreset: false, + isExpandable: true, + isBlank: false, + }, }; class List { @@ -249,7 +255,7 @@ class List { } getTypeInfo(type) { - return TYPES[type] || {}; + return TYPES[type] || TYPES.default; } onNewIssueResponse(issue, data) { diff --git a/app/assets/javascripts/branches/divergence_graph.js b/app/assets/javascripts/branches/divergence_graph.js index 670e8e9eb60..96bc6a5f8e8 100644 --- a/app/assets/javascripts/branches/divergence_graph.js +++ b/app/assets/javascripts/branches/divergence_graph.js @@ -1,23 +1,49 @@ import Vue from 'vue'; +import { __ } from '../locale'; +import createFlash from '../flash'; +import axios from '../lib/utils/axios_utils'; import DivergenceGraph from './components/divergence_graph.vue'; -export default () => { - document.querySelectorAll('.js-branch-divergence-graph').forEach(el => { - const { distance, aheadCount, behindCount, defaultBranch, maxCommits } = el.dataset; - - return new Vue({ - el, - render(h) { - return h(DivergenceGraph, { - props: { - defaultBranch, - distance: distance ? parseInt(distance, 10) : null, - aheadCount: parseInt(aheadCount, 10), - behindCount: parseInt(behindCount, 10), - maxCommits: parseInt(maxCommits, 10), - }, - }); - }, - }); +export function createGraphVueApp(el, data, maxCommits) { + return new Vue({ + el, + render(h) { + return h(DivergenceGraph, { + props: { + defaultBranch: 'master', + distance: data.distance ? parseInt(data.distance, 10) : null, + aheadCount: parseInt(data.ahead, 10), + behindCount: parseInt(data.behind, 10), + maxCommits, + }, + }); + }, }); +} + +export default endpoint => { + const names = [...document.querySelectorAll('.js-branch-item')].map( + ({ dataset }) => dataset.name, + ); + return axios + .get(endpoint, { + params: { names }, + }) + .then(({ data }) => { + const maxCommits = Object.entries(data).reduce((acc, [, val]) => { + const max = Math.max(...Object.values(val)); + return max > acc ? max : acc; + }, 100); + + Object.entries(data).forEach(([branchName, val]) => { + const el = document.querySelector(`.js-branch-${branchName} .js-branch-divergence-graph`); + + if (!el) return; + + createGraphVueApp(el, val, maxCommits); + }); + }) + .catch(() => + createFlash(__('Error fetching diverging counts for branches. Please try again.')), + ); }; diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index 4771090aa7e..cd2121db3b2 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -207,7 +207,7 @@ export default { return __('Updating'); } - return __('Updated'); + return this.updateSuccessful ? __('Updated to') : __('Updated'); }, updateFailureDescription() { return s__('ClusterIntegration|Update failed. Please check the logs and try again.'); @@ -331,8 +331,6 @@ export default { class="form-text text-muted label p-0 js-cluster-application-update-details" > {{ versionLabel }} - <span v-if="updateSuccessful">to</span> - <gl-link v-if="updateSuccessful" :href="chartRepo" diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue index 480228619a5..e26ef135bc5 100644 --- a/app/assets/javascripts/clusters/components/knative_domain_editor.vue +++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue @@ -2,7 +2,7 @@ import LoadingButton from '~/vue_shared/components/loading_button.vue'; import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; import { GlLoadingIcon } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; import { APPLICATION_STATUS } from '~/clusters/constants'; @@ -32,7 +32,7 @@ export default { return [UPDATING].includes(this.knative.status); }, saveButtonLabel() { - return this.saving ? this.__('Saving') : this.__('Save changes'); + return this.saving ? __('Saving') : __('Save changes'); }, knativeInstalled() { return this.knative.installed; @@ -122,9 +122,9 @@ export default { `ClusterIntegration|To access your application after deployment, point a wildcard DNS to the Knative Endpoint.`, ) }} - <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> - {{ __('More information') }} - </a> + <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">{{ + __('More information') + }}</a> </p> <p diff --git a/app/assets/javascripts/clusters/components/uninstall_application_button.vue b/app/assets/javascripts/clusters/components/uninstall_application_button.vue index ef4bcbe14dd..8465312d84d 100644 --- a/app/assets/javascripts/clusters/components/uninstall_application_button.vue +++ b/app/assets/javascripts/clusters/components/uninstall_application_button.vue @@ -1,6 +1,7 @@ <script> import LoadingButton from '~/vue_shared/components/loading_button.vue'; import { APPLICATION_STATUS } from '~/clusters/constants'; +import { __ } from '~/locale'; const { UPDATING, UNINSTALLING } = APPLICATION_STATUS; @@ -22,7 +23,7 @@ export default { return this.status === UNINSTALLING; }, label() { - return this.loading ? this.__('Uninstalling') : this.__('Uninstall'); + return this.loading ? __('Uninstalling') : __('Uninstall'); }, }, }; diff --git a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue index 65827f1cb6a..920439ebb23 100644 --- a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue +++ b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue @@ -14,7 +14,9 @@ const CUSTOM_APP_WARNING_TEXT = { [PROMETHEUS]: s__('ClusterIntegration|All data will be deleted and cannot be restored.'), [RUNNER]: s__('ClusterIntegration|Any running pipelines will be canceled.'), [KNATIVE]: s__('ClusterIntegration|The associated IP will be deleted and cannot be restored.'), - [JUPYTER]: '', + [JUPYTER]: s__( + 'ClusterIntegration|All data not committed to GitLab will be deleted and cannot be restored.', + ), }; export default { diff --git a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue new file mode 100644 index 00000000000..2aa5e9b3339 --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue @@ -0,0 +1,55 @@ +<script> +import { mapGetters } from 'vuex'; +import NoteSignedOutWidget from '~/notes/components/note_signed_out_widget.vue'; +import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; + +export default { + name: 'DiffDiscussionReply', + components: { + NoteSignedOutWidget, + ReplyPlaceholder, + UserAvatarLink, + }, + props: { + hasForm: { + type: Boolean, + required: false, + default: false, + }, + renderReplyPlaceholder: { + type: Boolean, + required: true, + }, + }, + computed: { + ...mapGetters({ + currentUser: 'getUserData', + userCanReply: 'userCanReply', + }), + }, +}; +</script> + +<template> + <div class="discussion-reply-holder d-flex clearfix"> + <template v-if="userCanReply"> + <slot v-if="hasForm" name="form"></slot> + <template v-else-if="renderReplyPlaceholder"> + <user-avatar-link + :link-href="currentUser.path" + :img-src="currentUser.avatar_url" + :img-alt="currentUser.name" + :img-size="40" + class="d-none d-sm-block" + /> + <reply-placeholder + class="qa-discussion-reply" + :button-text="__('Start a new discussion...')" + @onClick="$emit('showNewDiscussionForm')" + /> + </template> + </template> + <note-signed-out-widget v-else /> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue index 4c73eea4049..b0460bacff2 100644 --- a/app/assets/javascripts/diffs/components/diff_discussions.vue +++ b/app/assets/javascripts/diffs/components/diff_discussions.vue @@ -80,7 +80,6 @@ export default { v-show="isExpanded(discussion)" :discussion="discussion" :render-diff-file="false" - :always-expanded="true" :discussions-by-diff-order="true" :line="line" :help-page-path="helpPagePath" diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index eb9f1465945..4b226e30699 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -151,7 +151,11 @@ export default { stickyMonitor(this.$refs.header, contentTop() - fileHeaderHeight - 1, false); }, methods: { - ...mapActions('diffs', ['toggleFileDiscussions', 'toggleFullDiff']), + ...mapActions('diffs', [ + 'toggleFileDiscussions', + 'toggleFileDiscussionWrappers', + 'toggleFullDiff', + ]), handleToggleFile(e, checkTarget) { if ( !checkTarget || @@ -165,7 +169,7 @@ export default { this.$emit('showForkMessage'); }, handleToggleDiscussions() { - this.toggleFileDiscussions(this.diffFile); + this.toggleFileDiscussionWrappers(this.diffFile); }, handleFileNameClick(e) { const isLinkToOtherPage = diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue index e28909b7be3..af5550aec3b 100644 --- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue +++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue @@ -1,5 +1,4 @@ <script> -import { mapActions } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; import { pluralize, truncate } from '~/lib/utils/text_utility'; import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; @@ -19,11 +18,13 @@ export default { type: Array, required: true, }, + discussionsExpanded: { + type: Boolean, + required: false, + default: false, + }, }, computed: { - discussionsExpanded() { - return this.discussions.every(discussion => discussion.expanded); - }, allDiscussions() { return this.discussions.reduce((acc, note) => acc.concat(note.notes), []); }, @@ -45,26 +46,14 @@ export default { }, }, methods: { - ...mapActions(['toggleDiscussion']), getTooltipText(noteData) { let { note } = noteData; - if (note.length > LENGTH_OF_AVATAR_TOOLTIP) { note = truncate(note, LENGTH_OF_AVATAR_TOOLTIP); } return `${noteData.author.name}: ${note}`; }, - toggleDiscussions() { - const forceExpanded = this.discussions.some(discussion => !discussion.expanded); - - this.discussions.forEach(discussion => { - this.toggleDiscussion({ - discussionId: discussion.id, - forceExpanded, - }); - }); - }, }, }; </script> @@ -76,7 +65,7 @@ export default { type="button" :aria-label="__('Show comments')" class="diff-notes-collapse js-diff-comment-avatar js-diff-comment-button" - @click="toggleDiscussions" + @click="$emit('toggleLineDiscussions')" > <icon :size="12" name="collapse" /> </button> @@ -87,7 +76,7 @@ export default { :img-src="note.author.avatar_url" :tooltip-text="getTooltipText(note)" class="diff-comment-avatar js-diff-comment-avatar" - @click.native="toggleDiscussions" + @click.native="$emit('toggleLineDiscussions')" /> <span v-if="moreText" @@ -97,7 +86,7 @@ export default { data-container="body" data-placement="top" role="button" - @click="toggleDiscussions" + @click="$emit('toggleLineDiscussions')" >+{{ moreCount }}</span > </template> diff --git a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue index 1281f9b17ef..351110f0a87 100644 --- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue +++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue @@ -105,7 +105,13 @@ export default { }, }, methods: { - ...mapActions('diffs', ['loadMoreLines', 'showCommentForm', 'setHighlightedRow']), + ...mapActions('diffs', [ + 'loadMoreLines', + 'showCommentForm', + 'setHighlightedRow', + 'toggleLineDiscussions', + 'toggleLineDiscussionWrappers', + ]), handleCommentButton() { this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash }); }, @@ -184,7 +190,14 @@ export default { @click="setHighlightedRow(lineCode)" > </a> - <diff-gutter-avatars v-if="shouldShowAvatarsOnGutter" :discussions="line.discussions" /> + <diff-gutter-avatars + v-if="shouldShowAvatarsOnGutter" + :discussions="line.discussions" + :discussions-expanded="line.discussionsExpanded" + @toggleLineDiscussions=" + toggleLineDiscussions({ lineCode, fileHash, expanded: !line.discussionsExpanded }) + " + /> </template> </div> </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 1faa0493e79..ca3285e9afd 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue @@ -1,11 +1,14 @@ <script> -import diffDiscussions from './diff_discussions.vue'; -import diffLineNoteForm from './diff_line_note_form.vue'; +import { mapActions } from 'vuex'; +import DiffDiscussions from './diff_discussions.vue'; +import DiffLineNoteForm from './diff_line_note_form.vue'; +import DiffDiscussionReply from './diff_discussion_reply.vue'; export default { components: { - diffDiscussions, - diffLineNoteForm, + DiffDiscussions, + DiffLineNoteForm, + DiffDiscussionReply, }, props: { line: { @@ -32,10 +35,12 @@ export default { if (!this.line.discussions || !this.line.discussions.length) { return false; } - - return this.line.discussions.every(discussion => discussion.expanded); + return this.line.discussionsExpanded; }, }, + methods: { + ...mapActions('diffs', ['showCommentForm']), + }, }; </script> @@ -49,13 +54,22 @@ export default { :discussions="line.discussions" :help-page-path="helpPagePath" /> - <diff-line-note-form - v-if="line.hasForm" - :diff-file-hash="diffFileHash" - :line="line" - :note-target-line="line" - :help-page-path="helpPagePath" - /> + <diff-discussion-reply + :has-form="line.hasForm" + :render-reply-placeholder="Boolean(line.discussions.length)" + @showNewDiscussionForm=" + showCommentForm({ lineCode: line.line_code, fileHash: diffFileHash }) + " + > + <template #form> + <diff-line-note-form + :diff-file-hash="diffFileHash" + :line="line" + :note-target-line="line" + :help-page-path="helpPagePath" + /> + </template> + </diff-discussion-reply> </div> </td> </tr> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue index d2e54edca85..c00b0e010ff 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue @@ -1,11 +1,14 @@ <script> -import diffDiscussions from './diff_discussions.vue'; -import diffLineNoteForm from './diff_line_note_form.vue'; +import { mapActions } from 'vuex'; +import DiffDiscussions from './diff_discussions.vue'; +import DiffLineNoteForm from './diff_line_note_form.vue'; +import DiffDiscussionReply from './diff_discussion_reply.vue'; export default { components: { - diffDiscussions, - diffLineNoteForm, + DiffDiscussions, + DiffLineNoteForm, + DiffDiscussionReply, }, props: { line: { @@ -29,24 +32,30 @@ export default { computed: { hasExpandedDiscussionOnLeft() { return this.line.left && this.line.left.discussions.length - ? this.line.left.discussions.every(discussion => discussion.expanded) + ? this.line.left.discussionsExpanded : false; }, hasExpandedDiscussionOnRight() { return this.line.right && this.line.right.discussions.length - ? this.line.right.discussions.every(discussion => discussion.expanded) + ? this.line.right.discussionsExpanded : false; }, hasAnyExpandedDiscussion() { return this.hasExpandedDiscussionOnLeft || this.hasExpandedDiscussionOnRight; }, shouldRenderDiscussionsOnLeft() { - return this.line.left && this.line.left.discussions && this.hasExpandedDiscussionOnLeft; + return ( + this.line.left && + this.line.left.discussions && + this.line.left.discussions.length && + this.hasExpandedDiscussionOnLeft + ); }, shouldRenderDiscussionsOnRight() { return ( this.line.right && this.line.right.discussions && + this.line.right.discussions.length && this.hasExpandedDiscussionOnRight && this.line.right.type ); @@ -81,6 +90,22 @@ export default { return hasCommentFormOnLeft || hasCommentFormOnRight; }, + shouldRenderReplyPlaceholderOnLeft() { + return Boolean( + this.line.left && this.line.left.discussions && this.line.left.discussions.length, + ); + }, + shouldRenderReplyPlaceholderOnRight() { + return Boolean( + this.line.right && this.line.right.discussions && this.line.right.discussions.length, + ); + }, + }, + methods: { + ...mapActions('diffs', ['showCommentForm']), + showNewDiscussionForm() { + this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.diffFileHash }); + }, }, }; </script> @@ -90,37 +115,49 @@ export default { <td class="notes-content parallel old" colspan="2"> <div v-if="shouldRenderDiscussionsOnLeft" class="content"> <diff-discussions - v-if="line.left.discussions.length" :discussions="line.left.discussions" :line="line.left" :help-page-path="helpPagePath" /> </div> - <diff-line-note-form - v-if="showLeftSideCommentForm" - :diff-file-hash="diffFileHash" - :line="line.left" - :note-target-line="line.left" - :help-page-path="helpPagePath" - line-position="left" - /> + <diff-discussion-reply + :has-form="showLeftSideCommentForm" + :render-reply-placeholder="shouldRenderReplyPlaceholderOnLeft" + @showNewDiscussionForm="showNewDiscussionForm" + > + <template #form> + <diff-line-note-form + :diff-file-hash="diffFileHash" + :line="line.left" + :note-target-line="line.left" + :help-page-path="helpPagePath" + line-position="left" + /> + </template> + </diff-discussion-reply> </td> <td class="notes-content parallel new" colspan="2"> <div v-if="shouldRenderDiscussionsOnRight" class="content"> <diff-discussions - v-if="line.right.discussions.length" :discussions="line.right.discussions" :line="line.right" :help-page-path="helpPagePath" /> </div> - <diff-line-note-form - v-if="showRightSideCommentForm" - :diff-file-hash="diffFileHash" - :line="line.right" - :note-target-line="line.right" - line-position="right" - /> + <diff-discussion-reply + :has-form="showRightSideCommentForm" + :render-reply-placeholder="shouldRenderReplyPlaceholderOnRight" + @showNewDiscussionForm="showNewDiscussionForm" + > + <template #form> + <diff-line-note-form + :diff-file-hash="diffFileHash" + :line="line.right" + :note-target-line="line.right" + line-position="right" + /> + </template> + </diff-discussion-reply> </td> </tr> </template> diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 88d7b4bba63..32e0d8f42ee 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -12,6 +12,7 @@ import { getNoteFormData, convertExpandLines, idleCallback, + allDiscussionWrappersExpanded, } from './utils'; import * as types from './mutation_types'; import { @@ -79,6 +80,7 @@ export const assignDiscussionsToDiff = ( discussions = rootState.notes.discussions, ) => { const diffPositionByLineCode = getDiffPositionByLineCode(state.diffFiles); + const hash = getLocationHash(); discussions .filter(discussion => discussion.diff_discussion) @@ -86,6 +88,7 @@ export const assignDiscussionsToDiff = ( commit(types.SET_LINE_DISCUSSIONS_FOR_FILE, { discussion, diffPositionByLineCode, + hash, }); }); @@ -99,6 +102,10 @@ export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => { commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash: file_hash, lineCode: line_code, id }); }; +export const toggleLineDiscussions = ({ commit }, options) => { + commit(types.TOGGLE_LINE_DISCUSSIONS, options); +}; + export const renderFileForDiscussionId = ({ commit, rootState, state }, discussionId) => { const discussion = rootState.notes.discussions.find(d => d.id === discussionId); @@ -257,6 +264,31 @@ export const toggleFileDiscussions = ({ getters, dispatch }, diff) => { }); }; +export const toggleFileDiscussionWrappers = ({ commit }, diff) => { + const discussionWrappersExpanded = allDiscussionWrappersExpanded(diff); + let linesWithDiscussions; + if (diff.highlighted_diff_lines) { + linesWithDiscussions = diff.highlighted_diff_lines.filter(line => line.discussions.length); + } + if (diff.parallel_diff_lines) { + linesWithDiscussions = diff.parallel_diff_lines.filter( + line => + (line.left && line.left.discussions.length) || + (line.right && line.right.discussions.length), + ); + } + + if (linesWithDiscussions.length) { + linesWithDiscussions.forEach(line => { + commit(types.TOGGLE_LINE_DISCUSSIONS, { + fileHash: diff.file_hash, + lineCode: line.line_code, + expanded: !discussionWrappersExpanded, + }); + }); + } +}; + export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => { const postData = getNoteFormData({ commit: state.commit, @@ -267,7 +299,7 @@ export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => { return dispatch('saveNote', postData, { root: true }) .then(result => dispatch('updateDiscussion', result.discussion, { root: true })) .then(discussion => dispatch('assignDiscussionsToDiff', [discussion])) - .then(() => dispatch('updateResolvableDiscussonsCounts', null, { root: true })) + .then(() => dispatch('updateResolvableDiscussionsCounts', null, { root: true })) .then(() => dispatch('closeDiffFileCommentForm', formData.diffFile.file_hash)) .catch(() => createFlash(s__('MergeRequests|Saving the comment failed'))); }; diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index 8d6111da500..9db56331faa 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -35,3 +35,5 @@ export const ADD_CURRENT_VIEW_DIFF_FILE_LINES = 'ADD_CURRENT_VIEW_DIFF_FILE_LINE export const TOGGLE_DIFF_FILE_RENDERING_MORE = 'TOGGLE_DIFF_FILE_RENDERING_MORE'; export const SET_SHOW_SUGGEST_POPOVER = 'SET_SHOW_SUGGEST_POPOVER'; + +export const TOGGLE_LINE_DISCUSSIONS = 'TOGGLE_LINE_DISCUSSIONS'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 00181a63c43..a66f205bbbd 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -6,6 +6,7 @@ import { addContextLines, prepareDiffData, isDiscussionApplicableToLine, + updateLineInFile, } from './utils'; import * as types from './mutation_types'; @@ -109,7 +110,7 @@ export default { })); }, - [types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode }) { + [types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode, hash }) { const { latestDiff } = state; const discussionLineCode = discussion.line_code; @@ -130,13 +131,27 @@ export default { : [], }); + const setDiscussionsExpanded = line => { + const isLineNoteTargeted = line.discussions.some( + disc => disc.notes && disc.notes.find(note => hash === `note_${note.id}`), + ); + + return { + ...line, + discussionsExpanded: + line.discussions && line.discussions.length + ? line.discussions.some(disc => !disc.resolved) || isLineNoteTargeted + : false, + }; + }; + state.diffFiles = state.diffFiles.map(diffFile => { if (diffFile.file_hash === fileHash) { const file = { ...diffFile }; if (file.highlighted_diff_lines) { file.highlighted_diff_lines = file.highlighted_diff_lines.map(line => - lineCheck(line) ? mapDiscussions(line) : line, + setDiscussionsExpanded(lineCheck(line) ? mapDiscussions(line) : line), ); } @@ -148,8 +163,10 @@ export default { if (left || right) { return { ...line, - left: line.left ? mapDiscussions(line.left) : null, - right: line.right ? mapDiscussions(line.right, () => !left) : null, + left: line.left ? setDiscussionsExpanded(mapDiscussions(line.left)) : null, + right: line.right + ? setDiscussionsExpanded(mapDiscussions(line.right, () => !left)) + : null, }; } @@ -173,32 +190,11 @@ export default { [types.REMOVE_LINE_DISCUSSIONS_FOR_FILE](state, { fileHash, lineCode }) { const selectedFile = state.diffFiles.find(f => f.file_hash === fileHash); if (selectedFile) { - if (selectedFile.parallel_diff_lines) { - const targetLine = selectedFile.parallel_diff_lines.find( - line => - (line.left && line.left.line_code === lineCode) || - (line.right && line.right.line_code === lineCode), - ); - if (targetLine) { - const side = targetLine.left && targetLine.left.line_code === lineCode ? 'left' : 'right'; - - Object.assign(targetLine[side], { - discussions: targetLine[side].discussions.filter(discussion => discussion.notes.length), - }); - } - } - - if (selectedFile.highlighted_diff_lines) { - const targetInlineLine = selectedFile.highlighted_diff_lines.find( - line => line.line_code === lineCode, - ); - - if (targetInlineLine) { - Object.assign(targetInlineLine, { - discussions: targetInlineLine.discussions.filter(discussion => discussion.notes.length), - }); - } - } + updateLineInFile(selectedFile, lineCode, line => + Object.assign(line, { + discussions: line.discussions.filter(discussion => discussion.notes.length), + }), + ); if (selectedFile.discussions && selectedFile.discussions.length) { selectedFile.discussions = selectedFile.discussions.filter( @@ -207,6 +203,15 @@ export default { } } }, + + [types.TOGGLE_LINE_DISCUSSIONS](state, { fileHash, lineCode, expanded }) { + const selectedFile = state.diffFiles.find(f => f.file_hash === fileHash); + + updateLineInFile(selectedFile, lineCode, line => + Object.assign(line, { discussionsExpanded: expanded }), + ); + }, + [types.TOGGLE_FOLDER_OPEN](state, path) { state.treeEntries[path].opened = !state.treeEntries[path].opened; }, diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 71956255eef..1c3ed84001c 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -454,3 +454,48 @@ export const convertExpandLines = ({ }; export const idleCallback = cb => requestIdleCallback(cb); + +export const updateLineInFile = (selectedFile, lineCode, updateFn) => { + if (selectedFile.parallel_diff_lines) { + const targetLine = selectedFile.parallel_diff_lines.find( + line => + (line.left && line.left.line_code === lineCode) || + (line.right && line.right.line_code === lineCode), + ); + if (targetLine) { + const side = targetLine.left && targetLine.left.line_code === lineCode ? 'left' : 'right'; + + updateFn(targetLine[side]); + } + } + if (selectedFile.highlighted_diff_lines) { + const targetInlineLine = selectedFile.highlighted_diff_lines.find( + line => line.line_code === lineCode, + ); + + if (targetInlineLine) { + updateFn(targetInlineLine); + } + } +}; + +export const allDiscussionWrappersExpanded = diff => { + const discussionsExpandedArray = []; + if (diff.parallel_diff_lines) { + diff.parallel_diff_lines.forEach(line => { + if (line.left && line.left.discussions.length) { + discussionsExpandedArray.push(line.left.discussionsExpanded); + } + if (line.right && line.right.discussions.length) { + discussionsExpandedArray.push(line.right.discussionsExpanded); + } + }); + } else if (diff.highlighted_diff_lines) { + diff.parallel_diff_lines.forEach(line => { + if (line.discussions.length) { + discussionsExpandedArray.push(line.discussionsExpanded); + } + }); + } + return discussionsExpandedArray.every(el => el); +}; diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index 208bd19f6b0..21244c14977 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -1,5 +1,5 @@ <script> -import { s__, sprintf } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; import { formatTime } from '~/lib/utils/datetime_utility'; import Icon from '~/vue_shared/components/icon.vue'; import eventHub from '../event_hub'; @@ -28,7 +28,7 @@ export default { }, computed: { title() { - return 'Deploy to...'; + return __('Deploy to...'); }, }, methods: { @@ -80,7 +80,8 @@ export default { data-toggle="dropdown" > <span> - <icon name="play" /> <icon name="chevron-down" /> + <icon name="play" /> + <icon name="chevron-down" /> <gl-loading-icon v-if="isLoading" /> </span> </button> @@ -94,9 +95,10 @@ export default { class="js-manual-action-link no-btn btn d-flex align-items-center" @click="onClickAction(action)" > - <span class="flex-fill"> {{ action.name }} </span> + <span class="flex-fill">{{ action.name }}</span> <span v-if="action.scheduledAt" class="text-secondary"> - <icon name="clock" /> {{ remainingTime(action) }} + <icon name="clock" /> + {{ remainingTime(action) }} </span> </button> </li> diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index f0e80cba753..dc68443493c 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -1,4 +1,5 @@ <script> +import { __, sprintf } from '~/locale'; import Timeago from 'timeago.js'; import _ from 'underscore'; import { GlTooltipDirective } from '@gitlab/ui'; @@ -172,7 +173,9 @@ export default { this.model.last_deployment.user && this.model.last_deployment.user.username ) { - return `${this.model.last_deployment.user.username}'s avatar'`; + return sprintf(__("%{username}'s avatar"), { + username: this.model.last_deployment.user.username, + }); } return ''; }, @@ -293,6 +296,9 @@ export default { * @returns {Boolean|Undefined} */ isLastDeployment() { + // TODO: when the vue i18n rules are merged need to disable @gitlab/i18n/no-non-i18n-strings + // name: 'last?' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives + // Vue i18n ESLint rules issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/63560 return this.model && this.model.last_deployment && this.model.last_deployment['last?']; }, diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue index ae4f07a71cd..886490847ea 100644 --- a/app/assets/javascripts/environments/components/environment_monitoring.vue +++ b/app/assets/javascripts/environments/components/environment_monitoring.vue @@ -1,4 +1,5 @@ <script> +import { __ } from '~/locale'; /** * Renders the Monitoring (Metrics) link in environments table. */ @@ -21,7 +22,7 @@ export default { }, computed: { title() { - return 'Monitoring'; + return __('Monitoring'); }, }, }; diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue index 13195d32cc4..37f94f9f5ab 100644 --- a/app/assets/javascripts/environments/components/environment_terminal_button.vue +++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue @@ -5,6 +5,7 @@ */ import { GlTooltipDirective } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; +import { __ } from '~/locale'; export default { components: { @@ -27,7 +28,7 @@ export default { }, computed: { title() { - return 'Terminal'; + return __('Terminal'); }, }, }; diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue index 060d8e25227..ef1d1e49320 100644 --- a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue +++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue @@ -49,9 +49,9 @@ export default { </p> </div> <div class="form-group" :class="{ 'gl-show-field-errors': connectError }"> - <label class="label-bold" for="error-tracking-token">{{ - s__('ErrorTracking|Auth Token') - }}</label> + <label class="label-bold" for="error-tracking-token"> + {{ s__('ErrorTracking|Auth Token') }} + </label> <div class="row"> <div class="col-8 col-md-9 gl-pr-0"> <gl-form-input @@ -65,9 +65,8 @@ export default { <gl-button class="js-error-tracking-connect prepend-left-5" @click="$emit('handle-connect')" + >{{ __('Connect') }}</gl-button > - {{ __('Connect') }} - </gl-button> <icon v-show="connectSuccessful" class="js-error-tracking-connect-success prepend-left-5 text-success align-middle" diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue index 19bc3313373..4757c4b1e43 100644 --- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue +++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue @@ -59,7 +59,7 @@ export default { <template> <div> <div v-if="!isLocalStorageAvailable" class="dropdown-info-note"> - This feature requires local storage to be enabled + {{ __('This feature requires local storage to be enabled') }} </div> <ul v-else-if="hasItems"> <li v-for="(item, index) in processedItems" :key="`processed-items-${index}`"> @@ -90,10 +90,10 @@ export default { class="filtered-search-history-clear-button" @click="onRequestClearRecentSearches($event)" > - Clear recent searches + {{ __('Clear recent searches') }} </button> </li> </ul> - <div v-else class="dropdown-info-note">You don't have any recent searches</div> + <div v-else class="dropdown-info-note">{{ __("You don't have any recent searches") }}</div> </div> </template> diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js index 903c838e266..460174caf4d 100644 --- a/app/assets/javascripts/group.js +++ b/app/assets/javascripts/group.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import { slugifyWithHyphens } from './lib/utils/text_utility'; +import { slugify } from './lib/utils/text_utility'; export default class Group { constructor() { @@ -14,7 +14,7 @@ export default class Group { } update() { - const slug = slugifyWithHyphens(this.groupName.val()); + const slug = slugify(this.groupName.val()); this.groupPath.val(slug); } diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue index 4be4b02ac1e..c8fbc3cb9f1 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -107,7 +107,8 @@ export default { @click="openFileInEditor" > <span class="multi-file-commit-list-file-path d-flex align-items-center"> - <file-icon :file-name="file.name" class="append-right-8" />{{ file.name }} + <file-icon :file-name="file.name" class="append-right-8" /> + {{ file.name }} </span> <div class="ml-auto d-flex align-items-center"> <div class="d-flex align-items-center ide-commit-list-changed-icon"> diff --git a/app/assets/javascripts/ide/components/external_link.vue b/app/assets/javascripts/ide/components/external_link.vue index 954f84cea17..d1857f0176a 100644 --- a/app/assets/javascripts/ide/components/external_link.vue +++ b/app/assets/javascripts/ide/components/external_link.vue @@ -27,7 +27,7 @@ export default { target="_blank" rel="noopener noreferrer" > - <span class="vertical-align-middle">Open in file view</span> + <span class="vertical-align-middle">{{ __('Open in file view') }}</span> <icon :size="16" name="external-link" css-classes="vertical-align-middle space-right" /> </a> </div> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index b0c4969c5e4..5fcb11a232e 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -8,6 +8,7 @@ import { activityBarViews, viewerTypes } from '../constants'; import Editor from '../lib/editor'; import ExternalLink from './external_link.vue'; import FileTemplatesBar from './file_templates/bar.vue'; +import { __ } from '~/locale'; export default { components: { @@ -40,27 +41,36 @@ export default { }, showContentViewer() { return ( - (this.shouldHideEditor || this.file.viewMode === 'preview') && + (this.shouldHideEditor || this.isPreviewViewMode) && (this.viewer !== viewerTypes.mr || !this.file.mrChange) ); }, showDiffViewer() { return this.shouldHideEditor && this.file.mrChange && this.viewer === viewerTypes.mr; }, + isEditorViewMode() { + return this.file.viewMode === 'editor'; + }, + isPreviewViewMode() { + return this.file.viewMode === 'preview'; + }, editTabCSS() { return { - active: this.file.viewMode === 'editor', + active: this.isEditorViewMode, }; }, previewTabCSS() { return { - active: this.file.viewMode === 'preview', + active: this.isPreviewViewMode, }; }, fileType() { const info = viewerInformationForPath(this.file.path); return (info && info.id) || ''; }, + showEditor() { + return !this.shouldHideEditor && this.isEditorViewMode; + }, }, watch: { file(newVal, oldVal) { @@ -89,7 +99,7 @@ export default { } }, rightPanelCollapsed() { - this.editor.updateDimensions(); + this.refreshEditorDimensions(); }, viewer() { if (!this.file.pending) { @@ -98,11 +108,17 @@ export default { }, panelResizing() { if (!this.panelResizing) { - this.editor.updateDimensions(); + this.refreshEditorDimensions(); } }, rightPaneIsOpen() { - this.editor.updateDimensions(); + this.refreshEditorDimensions(); + }, + showEditor(val) { + if (val) { + // We need to wait for the editor to actually be rendered. + this.$nextTick(() => this.refreshEditorDimensions()); + } }, }, beforeDestroy() { @@ -145,7 +161,14 @@ export default { this.createEditorInstance(); }) .catch(err => { - flash('Error setting up editor. Please try again.', 'alert', document, null, false, true); + flash( + __('Error setting up editor. Please try again.'), + 'alert', + document, + null, + false, + true, + ); throw err; }); }, @@ -212,6 +235,11 @@ export default { eol: this.model.eol, }); }, + refreshEditorDimensions() { + if (this.showEditor) { + this.editor.updateDimensions(); + } + }, }, viewerTypes, }; @@ -227,12 +255,8 @@ export default { role="button" @click.prevent="setFileViewMode({ file, viewMode: 'editor' })" > - <template v-if="viewer === $options.viewerTypes.edit"> - {{ __('Edit') }} - </template> - <template v-else> - {{ __('Review') }} - </template> + <template v-if="viewer === $options.viewerTypes.edit">{{ __('Edit') }}</template> + <template v-else>{{ __('Review') }}</template> </a> </li> <li v-if="file.previewMode" :class="previewTabCSS"> @@ -240,16 +264,15 @@ export default { href="javascript:void(0);" role="button" @click.prevent="setFileViewMode({ file, viewMode: 'preview' })" + >{{ file.previewMode.previewTitle }}</a > - {{ file.previewMode.previewTitle }} - </a> </li> </ul> <external-link :file="file" /> </div> <file-templates-bar v-if="showFileTemplatesBar(file.name)" /> <div - v-show="!shouldHideEditor && file.viewMode === 'editor'" + v-show="showEditor" ref="editor" :class="{ 'is-readonly': isCommitModeActive, diff --git a/app/assets/javascripts/ide/components/repo_file_status_icon.vue b/app/assets/javascripts/ide/components/repo_file_status_icon.vue index a964d90b090..84a962bfc7d 100644 --- a/app/assets/javascripts/ide/components/repo_file_status_icon.vue +++ b/app/assets/javascripts/ide/components/repo_file_status_icon.vue @@ -1,4 +1,5 @@ <script> +import { __, sprintf } from '~/locale'; import icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; import '~/lib/utils/datetime_utility'; @@ -18,7 +19,9 @@ export default { }, computed: { lockTooltip() { - return `Locked by ${this.file.file_lock.user.name}`; + return sprintf(__(`Locked by %{fileLockUserName}`), { + fileLockUserName: this.file.file_lock.user.name, + }); }, }, }; diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue index f6aa2295844..7615cfc966e 100644 --- a/app/assets/javascripts/ide/components/repo_tab.vue +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -1,4 +1,5 @@ <script> +import { __, sprintf } from '~/locale'; import { mapActions } from 'vuex'; import FileIcon from '~/vue_shared/components/file_icon.vue'; @@ -27,9 +28,9 @@ export default { computed: { closeLabel() { if (this.fileHasChanged) { - return `${this.tab.name} changed`; + return sprintf(__(`%{tabname} changed`), { tabname: this.tab.name }); } - return `Close ${this.tab.name}`; + return sprintf(__(`Close %{tabname}`, { tabname: this.tab.name })); }, showChangedIcon() { if (this.tab.pending) return true; diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index 507dc363529..8c0119a1fed 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -62,7 +62,7 @@ export const createTempEntry = ( new Promise(resolve => { const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name; - if (state.entries[name]) { + if (state.entries[name] && !state.entries[name].deleted) { flash( `The name "${name.split('/').pop()}" is already taken in this directory.`, 'alert', @@ -208,6 +208,7 @@ export const deleteEntry = ({ commit, dispatch, state }, path) => { } commit(types.DELETE_ENTRY, path); + dispatch('stageChange', path); dispatch('triggerFilesChange'); }; diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index 01ca6a6b12f..ac34491c1ad 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -186,6 +186,8 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo commit(rootTypes.CLEAR_STAGED_CHANGES, null, { root: true }); + commit(rootTypes.CLEAR_REPLACED_FILES, null, { root: true }); + setTimeout(() => { commit(rootTypes.SET_LAST_COMMIT_MSG, '', { root: true }); }, 5000); diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index 86ab76136df..f021729c451 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -60,6 +60,8 @@ export const CLEAR_STAGED_CHANGES = 'CLEAR_STAGED_CHANGES'; export const STAGE_CHANGE = 'STAGE_CHANGE'; export const UNSTAGE_CHANGE = 'UNSTAGE_CHANGE'; +export const CLEAR_REPLACED_FILES = 'CLEAR_REPLACED_FILES'; + export const UPDATE_FILE_AFTER_COMMIT = 'UPDATE_FILE_AFTER_COMMIT'; export const ADD_PENDING_TAB = 'ADD_PENDING_TAB'; export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index ec4c2fdcde2..ea125214ebb 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -56,6 +56,11 @@ export default { stagedFiles: [], }); }, + [types.CLEAR_REPLACED_FILES](state) { + Object.assign(state, { + replacedFiles: [], + }); + }, [types.SET_ENTRIES](state, entries) { Object.assign(state, { entries, @@ -70,6 +75,13 @@ export default { Object.assign(state.entries, { [key]: entry, }); + } else if (foundEntry.deleted) { + Object.assign(state.entries, { + [key]: { + ...entry, + replaces: true, + }, + }); } else { const tree = entry.tree.filter( f => foundEntry.tree.find(e => e.path === f.path) === undefined, @@ -144,6 +156,7 @@ export default { raw: file.content, changed: Boolean(changedFile), staged: false, + replaces: false, prevPath: '', moved: false, lastCommitSha: lastCommit.commit.id, diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index 6ca246c1d63..c88244492e0 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -170,12 +170,16 @@ export default { entries: Object.assign(state.entries, { [path]: Object.assign(state.entries[path], { staged: true, - changed: false, }), }), }); if (stagedFile) { + Object.assign(state, { + replacedFiles: state.replacedFiles.concat({ + ...stagedFile, + }), + }); Object.assign(stagedFile, { ...state.entries[path], }); diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index d400b9831a9..c4da482bf0a 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -6,6 +6,7 @@ export default () => ({ currentMergeRequestId: '', changedFiles: [], stagedFiles: [], + replacedFiles: [], endpoints: {}, lastCommitMsg: '', lastCommitPath: '', diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index fb132c1afc1..01f78a29cf6 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -18,6 +18,7 @@ export const dataStructure = () => ({ active: false, changed: false, staged: false, + replaces: false, lastCommitPath: '', lastCommitSha: '', lastCommit: { @@ -119,7 +120,7 @@ export const commitActionForFile = file => { return commitActionTypes.move; } else if (file.deleted) { return commitActionTypes.delete; - } else if (file.tempFile) { + } else if (file.tempFile && !file.replaces) { return commitActionTypes.create; } @@ -151,7 +152,8 @@ export const createCommitPayload = ({ previous_path: f.prevPath === '' ? undefined : f.prevPath, content: f.prevPath ? null : f.content || undefined, encoding: f.base64 ? 'base64' : 'text', - last_commit_id: newBranch || f.deleted || f.prevPath ? undefined : f.lastCommitSha, + last_commit_id: + newBranch || f.deleted || f.prevPath || f.replaces ? undefined : f.lastCommitSha, })), start_sha: newBranch ? rootGetters.lastCommit.short_id : undefined, }); diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index 5857f9e22ae..c05db4a5c71 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -22,7 +22,7 @@ export default (resolvers = {}, config = {}) => { return new ApolloClient({ link: ApolloLink.split( - operation => operation.getContext().hasUpload, + operation => operation.getContext().hasUpload || operation.getContext().isSingleRequest, createUploadLink(httpOptions), new BatchHttpLink(httpOptions), ), diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index cc1d85fd97d..d38f59b5861 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -44,11 +44,18 @@ export const pluralize = (str, count) => str + (count > 1 || count === 0 ? 's' : export const dasherize = str => str.replace(/[_\s]+/g, '-'); /** - * Replaces whitespaces with hyphens and converts to lower case + * Replaces whitespaces with hyphens, convert to lower case and remove non-allowed special characters * @param {String} str * @returns {String} */ -export const slugifyWithHyphens = str => str.toLowerCase().replace(/\s+/g, '-'); +export const slugify = str => { + const slug = str + .trim() + .toLowerCase() + .replace(/[^a-zA-Z0-9_.-]+/g, '-'); + + return slug === '-' ? '' : slug; +}; /** * Replaces whitespaces with underscore and converts to lower case diff --git a/app/assets/javascripts/manual_ordering.js b/app/assets/javascripts/manual_ordering.js index e16ddbfef7e..012d1e70410 100644 --- a/app/assets/javascripts/manual_ordering.js +++ b/app/assets/javascripts/manual_ordering.js @@ -21,7 +21,7 @@ const updateIssue = (url, issueList, { move_before_id, move_after_id }) => const initManualOrdering = () => { const issueList = document.querySelector('.manual-ordering'); - if (!issueList || !(gon.features && gon.features.manualSorting)) { + if (!issueList || !(gon.features && gon.features.manualSorting) || !(gon.current_user_id > 0)) { return; } diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue index 9a3ce5174db..81773bd140e 100644 --- a/app/assets/javascripts/monitoring/components/charts/area.vue +++ b/app/assets/javascripts/monitoring/components/charts/area.vue @@ -1,5 +1,6 @@ <script> import { __ } from '~/locale'; +import { GlLink } from '@gitlab/ui'; import { GlAreaChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import dateFormat from 'dateformat'; import { debounceByAnimationFrame, roundOffFloat } from '~/lib/utils/common_utils'; @@ -14,6 +15,7 @@ export default { components: { GlAreaChart, GlChartSeriesLabel, + GlLink, Icon, }, inheritAttrs: false, @@ -44,6 +46,10 @@ export default { required: false, default: () => [], }, + projectPath: { + type: String, + required: true, + }, thresholds: { type: Array, required: false, @@ -55,6 +61,7 @@ export default { tooltip: { title: '', content: [], + commitUrl: '', isDeployment: false, sha: '', }, @@ -195,12 +202,13 @@ export default { this.tooltip.title = dateFormat(params.value, 'dd mmm yyyy, h:MMTT'); this.tooltip.content = []; params.seriesData.forEach(seriesData => { - if (seriesData.componentSubType === graphTypes.deploymentData) { - this.tooltip.isDeployment = true; + this.tooltip.isDeployment = seriesData.componentSubType === graphTypes.deploymentData; + if (this.tooltip.isDeployment) { const [deploy] = this.recentDeployments.filter( deployment => deployment.createdAt === seriesData.value[0], ); this.tooltip.sha = deploy.sha.substring(0, 8); + this.tooltip.commitUrl = deploy.commitUrl; } else { const { seriesName, color } = seriesData; // seriesData.value contains the chart's [x, y] value pair @@ -259,7 +267,7 @@ export default { </template> <div slot="tooltipContent" class="d-flex align-items-center"> <icon name="commit" class="mr-2" /> - {{ tooltip.sha }} + <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link> </div> </template> <template v-else> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 23687c54fd3..2cbda8ea05d 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -359,6 +359,7 @@ export default { <monitor-area-chart v-for="(graphData, graphIndex) in chartsWithData(groupData.metrics)" :key="graphIndex" + :project-path="projectPath" :graph-data="graphData" :deployment-data="deploymentData" :thresholds="getGraphAlertValues(graphData.queries)" diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index edbcf84b342..97d149e9ad5 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -8,10 +8,12 @@ export default (props = {}) => { const el = document.getElementById('prometheus-graphs'); if (el && el.dataset) { - store.dispatch('monitoringDashboard/setFeatureFlags', { - prometheusEndpointEnabled: gon.features.environmentMetricsUsePrometheusEndpoint, - multipleDashboardsEnabled: gon.features.environmentMetricsShowMultipleDashboards, - }); + if (gon.features) { + store.dispatch('monitoringDashboard/setFeatureFlags', { + prometheusEndpointEnabled: gon.features.environmentMetricsUsePrometheusEndpoint, + multipleDashboardsEnabled: gon.features.environmentMetricsShowMultipleDashboards, + }); + } const [currentDashboard] = getParameterValues('dashboard'); diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue index 22cca756ef6..f4570c1292c 100644 --- a/app/assets/javascripts/notes/components/discussion_actions.vue +++ b/app/assets/javascripts/notes/components/discussion_actions.vue @@ -40,7 +40,11 @@ export default { <template> <div class="discussion-with-resolve-btn"> - <reply-placeholder class="qa-discussion-reply" @onClick="$emit('showReplyForm')" /> + <reply-placeholder + :button-text="s__('MergeRequests|Reply...')" + class="qa-discussion-reply" + @onClick="$emit('showReplyForm')" + /> <resolve-discussion-button v-if="discussion.resolvable" :is-resolving="isResolving" @@ -53,6 +57,17 @@ export default { v-if="shouldShowJumpToNextDiscussion" @onClick="$emit('jumpToNextDiscussion')" /> + <resolve-with-issue-button + v-if="discussion.resolvable && resolveWithIssuePath" + :url="resolveWithIssuePath" + /> + </div> + + <div + v-if="discussion.resolvable && shouldShowJumpToNextDiscussion" + class="btn-group discussion-actions ml-sm-2" + > + <jump-to-next-discussion-button @onClick="$emit('jumpToNextDiscussion')" /> </div> </div> </template> diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue index 228bb652597..2ff0fee62f3 100644 --- a/app/assets/javascripts/notes/components/discussion_notes.vue +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -1,11 +1,11 @@ <script> -import { mapGetters } from 'vuex'; +import { mapGetters, mapActions } from 'vuex'; import { SYSTEM_NOTE } from '../constants'; import { __ } from '~/locale'; -import NoteableNote from './noteable_note.vue'; -import PlaceholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; -import PlaceholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; +import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; +import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue'; import SystemNote from '~/vue_shared/components/notes/system_note.vue'; +import NoteableNote from './noteable_note.vue'; import ToggleRepliesWidget from './toggle_replies_widget.vue'; import NoteEditedText from './note_edited_text.vue'; @@ -72,6 +72,7 @@ export default { }, }, methods: { + ...mapActions(['toggleDiscussion']), componentName(note) { if (note.isPlaceholderNote) { if (note.placeholderType === SYSTEM_NOTE) { @@ -101,12 +102,12 @@ export default { <component :is="componentName(firstNote)" :note="componentData(firstNote)" - :line="line" + :line="line || diffLine" :commit="commit" :help-page-path="helpPagePath" :show-reply-button="userCanReply" - @handle-delete-note="$emit('deleteNote')" - @start-replying="$emit('startReplying')" + @handleDeleteNote="$emit('deleteNote')" + @startReplying="$emit('startReplying')" > <note-edited-text v-if="discussion.resolved" @@ -118,23 +119,29 @@ export default { /> <slot slot="avatar-badge" name="avatar-badge"></slot> </component> - <toggle-replies-widget - v-if="hasReplies" - :collapsed="!isExpanded" - :replies="replies" - @toggle="$emit('toggleDiscussion')" - /> - <template v-if="isExpanded"> - <component - :is="componentName(note)" - v-for="note in replies" - :key="note.id" - :note="componentData(note)" - :help-page-path="helpPagePath" - :line="line" - @handle-delete-note="$emit('deleteNote')" + <div + :class="discussion.diff_discussion ? 'discussion-collapsible bordered-box clearfix' : ''" + > + <toggle-replies-widget + v-if="hasReplies" + :collapsed="!isExpanded" + :replies="replies" + :class="{ 'discussion-toggle-replies': discussion.diff_discussion }" + @toggle="toggleDiscussion({ discussionId: discussion.id })" /> - </template> + <template v-if="isExpanded"> + <component + :is="componentName(note)" + v-for="note in replies" + :key="note.id" + :note="componentData(note)" + :help-page-path="helpPagePath" + :line="line" + @handleDeleteNote="$emit('deleteNote')" + /> + </template> + <slot :show-replies="isExpanded || !hasReplies" name="footer"></slot> + </div> </template> <template v-else> <component @@ -144,12 +151,12 @@ export default { :note="componentData(note)" :help-page-path="helpPagePath" :line="diffLine" - @handle-delete-note="$emit('deleteNote')" + @handleDeleteNote="$emit('deleteNote')" > <slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot> </component> + <slot :show-replies="isExpanded || !hasReplies" name="footer"></slot> </template> </ul> - <slot :show-replies="isExpanded || !hasReplies" name="footer"></slot> </div> </template> diff --git a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue index ea590905e3c..0204169214b 100644 --- a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue +++ b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue @@ -1,6 +1,12 @@ <script> export default { name: 'ReplyPlaceholder', + props: { + buttonText: { + type: String, + required: true, + }, + }, }; </script> @@ -12,6 +18,6 @@ export default { :title="s__('MergeRequests|Add a reply')" @click="$emit('onClick')" > - {{ s__('MergeRequests|Reply...') }} + {{ buttonText }} </button> </template> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 10b15a9c38c..f6b5fffde29 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -126,16 +126,13 @@ export default { return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved'); }, shouldShowJumpToNextDiscussion() { - return this.showJumpToNextDiscussion( - this.discussion.id, - this.discussionsByDiffOrder ? 'diff' : 'discussion', - ); + return this.showJumpToNextDiscussion(this.discussionsByDiffOrder ? 'diff' : 'discussion'); }, shouldRenderDiffs() { return this.discussion.diff_discussion && this.renderDiffFile; }, shouldGroupReplies() { - return !this.shouldRenderDiffs && !this.discussion.diff_discussion; + return !this.shouldRenderDiffs; }, wrapperComponent() { return this.shouldRenderDiffs ? diffWithNote : 'div'; @@ -253,6 +250,11 @@ export default { clearDraft(this.autosaveKey); }, saveReply(noteText, form, callback) { + if (!noteText) { + this.cancelReplyForm(); + callback(); + return; + } const postData = { in_reply_to_discussion_id: this.discussion.reply_id, target_type: this.getNoteableData.targetType, @@ -366,7 +368,6 @@ Please check your network connection and try again.`; :line="line" :should-group-replies="shouldGroupReplies" @startReplying="showReplyForm" - @toggleDiscussion="toggleDiscussionHandler" @deleteNote="deleteNoteHandler" > <slot slot="avatar-badge" name="avatar-badge"></slot> @@ -379,7 +380,7 @@ Please check your network connection and try again.`; <div v-else-if="showReplies" :class="{ 'is-replying': isReplying }" - class="discussion-reply-holder" + class="discussion-reply-holder clearfix" > <user-avatar-link v-if="!isReplying && userCanReply" diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 63658d49a05..9054b4779aa 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -51,7 +51,7 @@ export const fetchDiscussions = ({ commit, dispatch }, { path, filter }) => .then(res => res.json()) .then(discussions => { commit(types.SET_INITIAL_DISCUSSIONS, discussions); - dispatch('updateResolvableDiscussonsCounts'); + dispatch('updateResolvableDiscussionsCounts'); }); export const updateDiscussion = ({ commit, state }, discussion) => { @@ -67,7 +67,7 @@ export const deleteNote = ({ commit, dispatch, state }, note) => commit(types.DELETE_NOTE, note); dispatch('updateMergeRequestWidget'); - dispatch('updateResolvableDiscussonsCounts'); + dispatch('updateResolvableDiscussionsCounts'); if (isInMRPage()) { dispatch('diffs/removeDiscussionsFromDiff', discussion); @@ -117,7 +117,7 @@ export const replyToDiscussion = ({ commit, state, getters, dispatch }, { endpoi dispatch('updateMergeRequestWidget'); dispatch('startTaskList'); - dispatch('updateResolvableDiscussonsCounts'); + dispatch('updateResolvableDiscussionsCounts'); } else { commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res); } @@ -135,7 +135,7 @@ export const createNewNote = ({ commit, dispatch }, { endpoint, data }) => dispatch('updateMergeRequestWidget'); dispatch('startTaskList'); - dispatch('updateResolvableDiscussonsCounts'); + dispatch('updateResolvableDiscussionsCounts'); } return res; }); @@ -168,7 +168,7 @@ export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved, commit(mutationType, res); - dispatch('updateResolvableDiscussonsCounts'); + dispatch('updateResolvableDiscussionsCounts'); dispatch('updateMergeRequestWidget'); }); @@ -442,7 +442,7 @@ export const startTaskList = ({ dispatch }) => }), ); -export const updateResolvableDiscussonsCounts = ({ commit }) => +export const updateResolvableDiscussionsCounts = ({ commit }) => commit(types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS); export const submitSuggestion = ( diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index d7982be3e4b..8aa8f5037b3 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -61,15 +61,13 @@ export const unresolvedDiscussionsCount = state => state.unresolvedDiscussionsCo export const resolvableDiscussionsCount = state => state.resolvableDiscussionsCount; export const hasUnresolvedDiscussions = state => state.hasUnresolvedDiscussions; -export const showJumpToNextDiscussion = (state, getters) => (discussionId, mode = 'discussion') => { +export const showJumpToNextDiscussion = (state, getters) => (mode = 'discussion') => { const orderedDiffs = mode !== 'discussion' ? getters.unresolvedDiscussionsIdsByDiff : getters.unresolvedDiscussionsIdsByDate; - const indexOf = orderedDiffs.indexOf(discussionId); - - return indexOf !== -1 && indexOf < orderedDiffs.length - 1; + return orderedDiffs.length > 1; }; export const isDiscussionResolved = (state, getters) => discussionId => diff --git a/app/assets/javascripts/pages/projects/branches/index/index.js b/app/assets/javascripts/pages/projects/branches/index/index.js index 29de3b7806c..37e8c75f299 100644 --- a/app/assets/javascripts/pages/projects/branches/index/index.js +++ b/app/assets/javascripts/pages/projects/branches/index/index.js @@ -5,5 +5,5 @@ import initDiverganceGraph from '~/branches/divergence_graph'; document.addEventListener('DOMContentLoaded', () => { AjaxLoadingSpinner.init(); new DeleteModal(); // eslint-disable-line no-new - initDiverganceGraph(); + initDiverganceGraph(document.querySelector('.js-branch-list').dataset.divergingCountsEndpoint); }); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue index ff6dadeff7d..533065b2d4d 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue @@ -1,5 +1,6 @@ <script> -import projectFeatureToggle from '../../../../../vue_shared/components/toggle_button.vue'; +import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue'; +import { featureAccessLevelNone } from '../constants'; export default { components: { @@ -43,7 +44,7 @@ export default { if (this.featureEnabled) { return this.options; } - return [[0, 'Enable feature to choose access level']]; + return [featureAccessLevelNone]; }, displaySelectInput() { diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index dea7c586868..b4d24f3aa36 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -1,16 +1,26 @@ <script> +import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin'; +import { __ } from '~/locale'; import projectFeatureSetting from './project_feature_setting.vue'; -import projectFeatureToggle from '../../../../../vue_shared/components/toggle_button.vue'; +import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue'; import projectSettingRow from './project_setting_row.vue'; -import { visibilityOptions, visibilityLevelDescriptions } from '../constants'; +import { + visibilityOptions, + visibilityLevelDescriptions, + featureAccessLevelMembers, + featureAccessLevelEveryone, +} from '../constants'; import { toggleHiddenClassBySelector } from '../external'; +const PAGE_FEATURE_ACCESS_LEVEL = __('Everyone'); + export default { components: { projectFeatureSetting, projectFeatureToggle, projectSettingRow, }, + mixins: [settingsMixin], props: { currentSettings: { @@ -37,6 +47,11 @@ export default { required: false, default: false, }, + packagesAvailable: { + type: Boolean, + required: false, + default: false, + }, visibilityHelpPath: { type: String, required: false, @@ -67,8 +82,12 @@ export default { required: false, default: '', }, + packagesHelpPath: { + type: String, + required: false, + default: '', + }, }, - data() { const defaults = { visibilityOptions, @@ -91,9 +110,9 @@ export default { computed: { featureAccessLevelOptions() { - const options = [[10, 'Only Project Members']]; + const options = [featureAccessLevelMembers]; if (this.visibilityLevel !== visibilityOptions.PRIVATE) { - options.push([20, 'Everyone With Access']); + options.push(featureAccessLevelEveryone); } return options; }, @@ -106,7 +125,7 @@ export default { pagesFeatureAccessLevelOptions() { if (this.visibilityLevel !== visibilityOptions.PUBLIC) { - return this.featureAccessLevelOptions.concat([[30, 'Everyone']]); + return this.featureAccessLevelOptions.concat([[30, PAGE_FEATURE_ACCESS_LEVEL]]); } return this.featureAccessLevelOptions; }, @@ -148,24 +167,6 @@ export default { } }, - repositoryAccessLevel(value, oldValue) { - if (value < oldValue) { - // sub-features cannot have more premissive access level - this.mergeRequestsAccessLevel = Math.min(this.mergeRequestsAccessLevel, value); - this.buildsAccessLevel = Math.min(this.buildsAccessLevel, value); - - if (value === 0) { - this.containerRegistryEnabled = false; - this.lfsEnabled = false; - } - } else if (oldValue === 0) { - this.mergeRequestsAccessLevel = value; - this.buildsAccessLevel = value; - this.containerRegistryEnabled = true; - this.lfsEnabled = true; - } - }, - issuesAccessLevel(value, oldValue) { if (value === 0) toggleHiddenClassBySelector('.issues-feature', true); else if (oldValue === 0) toggleHiddenClassBySelector('.issues-feature', false); @@ -207,23 +208,20 @@ export default { <option :value="visibilityOptions.PRIVATE" :disabled="!visibilityAllowed(visibilityOptions.PRIVATE)" + >{{ __('Private') }}</option > - Private - </option> <option :value="visibilityOptions.INTERNAL" :disabled="!visibilityAllowed(visibilityOptions.INTERNAL)" + >{{ __('Internal') }}</option > - Internal - </option> <option :value="visibilityOptions.PUBLIC" :disabled="!visibilityAllowed(visibilityOptions.PUBLIC)" + >{{ __('Public') }}</option > - Public - </option> </select> - <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"> </i> + <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i> </div> </div> <span class="form-text text-muted">{{ visibilityLevelDescription }}</span> @@ -299,6 +297,18 @@ export default { name="project[lfs_enabled]" /> </project-setting-row> + <project-setting-row + v-if="packagesAvailable" + :help-path="packagesHelpPath" + label="Packages" + help-text="Every project can have its own space to store its packages" + > + <project-feature-toggle + v-model="packagesEnabled" + :disabled-input="!repositoryEnabled" + name="project[packages_enabled]" + /> + </project-setting-row> </div> <project-setting-row label="Wiki" help-text="Pages for project documentation"> <project-feature-setting diff --git a/app/assets/javascripts/pages/projects/shared/permissions/constants.js b/app/assets/javascripts/pages/projects/shared/permissions/constants.js index ac0dca31c37..73269c6f3ba 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/constants.js +++ b/app/assets/javascripts/pages/projects/shared/permissions/constants.js @@ -15,3 +15,30 @@ export const visibilityLevelDescriptions = { 'The project can be accessed by anyone, regardless of authentication.', ), }; + +const featureAccessLevel = { + NOT_ENABLED: 0, + PROJECT_MEMBERS: 10, + EVERYONE: 20, +}; + +const featureAccessLevelDescriptions = { + [featureAccessLevel.NOT_ENABLED]: __('Enable feature to choose access level'), + [featureAccessLevel.PROJECT_MEMBERS]: __('Only Project Members'), + [featureAccessLevel.EVERYONE]: __('Everyone With Access'), +}; + +export const featureAccessLevelNone = [ + featureAccessLevel.NOT_ENABLED, + featureAccessLevelDescriptions[featureAccessLevel.NOT_ENABLED], +]; + +export const featureAccessLevelMembers = [ + featureAccessLevel.PROJECT_MEMBERS, + featureAccessLevelDescriptions[featureAccessLevel.PROJECT_MEMBERS], +]; + +export const featureAccessLevelEveryone = [ + featureAccessLevel.EVERYONE, + featureAccessLevelDescriptions[featureAccessLevel.EVERYONE], +]; diff --git a/app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js b/app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js new file mode 100644 index 00000000000..fcbd81416f2 --- /dev/null +++ b/app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js @@ -0,0 +1,26 @@ +export default { + data() { + return { + packagesEnabled: false, + }; + }, + watch: { + repositoryAccessLevel(value, oldValue) { + if (value < oldValue) { + // sub-features cannot have more premissive access level + this.mergeRequestsAccessLevel = Math.min(this.mergeRequestsAccessLevel, value); + this.buildsAccessLevel = Math.min(this.buildsAccessLevel, value); + + if (value === 0) { + this.containerRegistryEnabled = false; + this.lfsEnabled = false; + } + } else if (oldValue === 0) { + this.mergeRequestsAccessLevel = value; + this.buildsAccessLevel = value; + this.containerRegistryEnabled = true; + this.lfsEnabled = true; + } + }, + }, +}; diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index 8f3ba9779fb..d5f1cea8356 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -92,7 +92,9 @@ export default { </template> <template v-else> <tr> - <td>No {{ header.toLowerCase() }} for this request.</td> + <td> + {{ sprintf(__('No %{header} for this request.'), { header: header.toLowerCase() }) }} + </td> </tr> </template> </table> diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index 48515cf785c..015c1527500 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -4,13 +4,12 @@ import { glEmojiTag } from '~/emoji'; import detailedMetric from './detailed_metric.vue'; import requestSelector from './request_selector.vue'; -import simpleMetric from './simple_metric.vue'; +import { s__ } from '~/locale'; export default { components: { detailedMetric, requestSelector, - simpleMetric, }, props: { store: { @@ -35,15 +34,20 @@ export default { }, }, detailedMetrics: [ - { metric: 'pg', header: 'SQL queries', details: 'queries', keys: ['sql'] }, + { metric: 'pg', header: s__('PerformanceBar|SQL queries'), details: 'queries', keys: ['sql'] }, { metric: 'gitaly', - header: 'Gitaly calls', + header: s__('PerformanceBar|Gitaly calls'), details: 'details', keys: ['feature', 'request'], }, + { + metric: 'redis', + header: 'Redis calls', + details: 'details', + keys: ['cmd'], + }, ], - simpleMetrics: ['redis'], data() { return { currentRequestId: '' }; }, @@ -99,7 +103,8 @@ export default { class="current-host" :class="{ canary: currentRequest.details.host.canary }" > - <span v-html="birdEmoji"></span> {{ currentRequest.details.host.hostname }} + <span v-html="birdEmoji"></span> + {{ currentRequest.details.host.hostname }} </span> </div> <detailed-metric @@ -118,16 +123,10 @@ export default { data-toggle="modal" data-target="#modal-peek-line-profile" > - profile + {{ s__('PerformanceBar|profile') }} </button> - <a v-else :href="profileUrl"> profile </a> + <a v-else :href="profileUrl">{{ s__('PerformanceBar|profile') }}</a> </div> - <simple-metric - v-for="metric in $options.simpleMetrics" - :key="metric" - :current-request="currentRequest" - :metric="metric" - /> <div id="peek-view-gc" class="view"> <span v-if="currentRequest.details" class="bold"> <span title="Invoke Time">{{ currentRequest.details.gc.gc_time }}</span @@ -139,7 +138,7 @@ export default { id="peek-view-trace" class="view" > - <a :href="currentRequest.details.tracing.tracing_url"> trace </a> + <a :href="currentRequest.details.tracing.tracing_url">{{ s__('PerformanceBar|trace') }}</a> </div> <request-selector v-if="currentRequest" diff --git a/app/assets/javascripts/performance_bar/components/simple_metric.vue b/app/assets/javascripts/performance_bar/components/simple_metric.vue deleted file mode 100644 index 358a57d5bc5..00000000000 --- a/app/assets/javascripts/performance_bar/components/simple_metric.vue +++ /dev/null @@ -1,33 +0,0 @@ -<script> -export default { - props: { - currentRequest: { - type: Object, - required: true, - }, - metric: { - type: String, - required: true, - }, - }, - computed: { - duration() { - return ( - this.currentRequest.details[this.metric] && - this.currentRequest.details[this.metric].duration - ); - }, - calls() { - return ( - this.currentRequest.details[this.metric] && this.currentRequest.details[this.metric].calls - ); - }, - }, -}; -</script> -<template> - <div :id="`peek-view-${metric}`" class="view"> - <span v-if="currentRequest.details" class="bold"> {{ duration }} / {{ calls }} </span> - {{ metric }} - </div> -</template> diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index b2e365e5cde..39afa87afc3 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -2,6 +2,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import ciHeader from '../../vue_shared/components/header_ci_component.vue'; import eventHub from '../event_hub'; +import { __ } from '~/locale'; export default { name: 'PipelineHeaderSection', @@ -54,7 +55,7 @@ export default { if (this.pipeline.retry_path) { actions.push({ - label: 'Retry', + label: __('Retry'), path: this.pipeline.retry_path, cssClass: 'js-retry-button btn btn-inverted-secondary', type: 'button', @@ -64,7 +65,7 @@ export default { if (this.pipeline.cancel_path) { actions.push({ - label: 'Cancel running', + label: __('Cancel running'), path: this.pipeline.cancel_path, cssClass: 'js-btn-cancel-pipeline btn btn-danger', type: 'button', diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue index 65a2b61396c..3f021a26ec5 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue @@ -94,9 +94,8 @@ export default { tabindex="0" class="js-pipeline-url-autodevops badge badge-info autodevops-badge" role="button" + >{{ __('Auto DevOps') }}</gl-link > - Auto DevOps - </gl-link> <span v-if="pipeline.flags.stuck" class="js-pipeline-url-stuck badge badge-warning"> {{ __('stuck') }} </span> diff --git a/app/assets/javascripts/pipelines/components/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_table.vue index 03d332cd430..d3ba0c97f6b 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table.vue @@ -44,6 +44,11 @@ export default { cancelingPipeline: null, }; }, + watch: { + pipelines() { + this.cancelingPipeline = null; + }, + }, created() { eventHub.$on('openConfirmationModal', this.setModalData); }, diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue index e32e2f785bd..5275de3bc8b 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue @@ -241,7 +241,11 @@ export default { return this.cancelingPipeline === this.pipeline.id; }, }, - + watch: { + pipeline() { + this.isRetrying = false; + }, + }, methods: { handleCancelClick() { eventHub.$emit('openConfirmationModal', { diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js index 3cc9d0a3a4e..a6243366375 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines.js @@ -107,8 +107,8 @@ export default { } // Stop polling this.poll.stop(); - // Update the table - return this.getPipelines().then(() => this.poll.restart()); + // Restarting the poll also makes an initial request + this.poll.restart(); }, fetchPipelines() { if (!this.isMakingRequest) { @@ -153,7 +153,7 @@ export default { postAction(endpoint) { this.service .postAction(endpoint) - .then(() => this.fetchPipelines()) + .then(() => this.updateTable()) .catch(() => Flash(__('An error occurred while making the request.'))); }, }, diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index ea82ff4e340..9066844f687 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils'; -import { slugifyWithHyphens } from '../lib/utils/text_utility'; +import { slugify } from '../lib/utils/text_utility'; import { s__ } from '~/locale'; let hasUserDefinedProjectPath = false; @@ -34,7 +34,7 @@ const deriveProjectPathFromUrl = $projectImportUrl => { }; const onProjectNameChange = ($projectNameInput, $projectPathInput) => { - const slug = slugifyWithHyphens($projectNameInput.val()); + const slug = slugify($projectNameInput.val()); $projectPathInput.val(slug); }; diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue index bfc55013a71..03281aa1317 100644 --- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue +++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue @@ -3,7 +3,7 @@ import Visibility from 'visibilityjs'; import ciIcon from '~/vue_shared/components/ci_icon.vue'; import Poll from '~/lib/utils/poll'; import Flash from '~/flash'; -import { s__, sprintf } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; import tooltip from '~/vue_shared/directives/tooltip'; import { GlLoadingIcon } from '@gitlab/ui'; import CommitPipelineService from '../services/commit_pipeline_service'; @@ -56,7 +56,7 @@ export default { }, errorCallback() { this.ciStatus = { - text: 'not found', + text: __('not found'), icon: 'status_notfound', group: 'notfound', }; diff --git a/app/assets/javascripts/registry/components/app.vue b/app/assets/javascripts/registry/components/app.vue index ee973017387..7752723baac 100644 --- a/app/assets/javascripts/registry/components/app.vue +++ b/app/assets/javascripts/registry/components/app.vue @@ -3,22 +3,81 @@ import { mapGetters, mapActions } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; import store from '../stores'; import CollapsibleContainer from './collapsible_container.vue'; +import SvgMessage from './svg_message.vue'; +import { s__, sprintf } from '../../locale'; export default { name: 'RegistryListApp', components: { CollapsibleContainer, GlLoadingIcon, + SvgMessage, }, props: { endpoint: { type: String, required: true, }, + characterError: { + type: Boolean, + required: false, + default: false, + }, + helpPagePath: { + type: String, + required: true, + }, + noContainersImage: { + type: String, + required: true, + }, + containersErrorImage: { + type: String, + required: true, + }, + repositoryUrl: { + type: String, + required: true, + }, }, store, computed: { ...mapGetters(['isLoading', 'repos']), + dockerConnectionErrorText() { + return sprintf( + s__(`ContainerRegistry|We are having trouble connecting to Docker, which could be due to an + issue with your project name or path. For more information, please review the + %{docLinkStart}Container Registry documentation%{docLinkEnd}.`), + { + docLinkStart: `<a href="${this.helpPagePath}#docker-connection-error">`, + docLinkEnd: '</a>', + }, + false, + ); + }, + introText() { + return sprintf( + s__(`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every + project can have its own space to store its Docker images. Learn more about the + %{docLinkStart}Container Registry%{docLinkEnd}.`), + { + docLinkStart: `<a href="${this.helpPagePath}">`, + docLinkEnd: '</a>', + }, + false, + ); + }, + noContainerImagesText() { + return sprintf( + s__(`ContainerRegistry|With the Container Registry, every project can have its own space to + store its Docker images. Learn more about the %{docLinkStart}Container Registry%{docLinkEnd}.`), + { + docLinkStart: `<a href="${this.helpPagePath}">`, + docLinkEnd: '</a>', + }, + false, + ); + }, }, created() { this.setMainEndpoint(this.endpoint); @@ -33,20 +92,44 @@ export default { </script> <template> <div> - <gl-loading-icon v-if="isLoading" size="md" /> + <svg-message v-if="characterError" id="invalid-characters" :svg-path="containersErrorImage"> + <h4> + {{ s__('ContainerRegistry|Docker connection error') }} + </h4> + <p v-html="dockerConnectionErrorText"></p> + </svg-message> + + <gl-loading-icon v-else-if="isLoading" size="md" class="prepend-top-16" /> + + <div v-else-if="!isLoading && !characterError && repos.length"> + <h4>{{ s__('ContainerRegistry|Container Registry') }}</h4> + <p v-html="introText"></p> + <collapsible-container v-for="item in repos" :key="item.id" :repo="item" /> + </div> + + <svg-message + v-else-if="!isLoading && !characterError && !repos.length" + id="no-container-images" + :svg-path="noContainersImage" + > + <h4> + {{ s__('ContainerRegistry|There are no container images stored for this project') }} + </h4> + <p v-html="noContainerImagesText"></p> - <collapsible-container - v-for="item in repos" - v-else-if="!isLoading && repos.length" - :key="item.id" - :repo="item" - /> + <h5>{{ s__('ContainerRegistry|Quick Start') }}</h5> + <p> + {{ + s__( + 'ContainerRegistry|You can add an image to this registry with the following commands:', + ) + }} + </p> - <p v-else-if="!isLoading && !repos.length"> - {{ - __(`No container images stored for this project. - Add one by following the instructions above.`) - }} - </p> + <pre> + docker build -t {{ repositoryUrl }} . + docker push {{ repositoryUrl }} + </pre> + </svg-message> </div> </template> diff --git a/app/assets/javascripts/registry/components/svg_message.vue b/app/assets/javascripts/registry/components/svg_message.vue new file mode 100644 index 00000000000..d0d44bf2d14 --- /dev/null +++ b/app/assets/javascripts/registry/components/svg_message.vue @@ -0,0 +1,24 @@ +<script> +export default { + name: 'RegistrySvgMessage', + props: { + id: { + type: String, + required: true, + }, + svgPath: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div :id="id" class="empty-state container-message mw-70p"> + <div class="svg-content"> + <img :src="svgPath" class="flex-align-self-center" /> + </div> + <slot></slot> + </div> +</template> diff --git a/app/assets/javascripts/registry/index.js b/app/assets/javascripts/registry/index.js index 025afefe7f0..d8daec29fda 100644 --- a/app/assets/javascripts/registry/index.js +++ b/app/assets/javascripts/registry/index.js @@ -14,12 +14,22 @@ export default () => const { dataset } = document.querySelector(this.$options.el); return { endpoint: dataset.endpoint, + characterError: Boolean(dataset.characterError), + helpPagePath: dataset.helpPagePath, + noContainersImage: dataset.noContainersImage, + containersErrorImage: dataset.containersErrorImage, + repositoryUrl: dataset.repositoryUrl, }; }, render(createElement) { return createElement('registry-app', { props: { endpoint: this.endpoint, + characterError: this.characterError, + helpPagePath: this.helpPagePath, + noContainersImage: this.noContainersImage, + containersErrorImage: this.containersErrorImage, + repositoryUrl: this.repositoryUrl, }, }); }, diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue index f510b905a2e..0031ba04d78 100644 --- a/app/assets/javascripts/releases/components/release_block.vue +++ b/app/assets/javascripts/releases/components/release_block.vue @@ -4,7 +4,7 @@ import { GlTooltipDirective, GlLink, GlBadge } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; -import { sprintf } from '../../locale'; +import { __, sprintf } from '../../locale'; export default { name: 'ReleaseBlock', @@ -27,13 +27,13 @@ export default { }, computed: { releasedTimeAgo() { - return sprintf('released %{time}', { - time: this.timeFormated(this.release.created_at), + return sprintf(__('released %{time}'), { + time: this.timeFormated(this.release.released_at), }); }, userImageAltDescription() { return this.author && this.author.username - ? sprintf("%{username}'s avatar", { username: this.author.username }) + ? sprintf(__("%{username}'s avatar"), { username: this.author.username }) : null; }, commit() { @@ -56,8 +56,8 @@ export default { <div class="card-body"> <h2 class="card-title mt-0"> {{ release.name }} - <gl-badge v-if="release.pre_release" variant="warning" class="align-middle">{{ - __('Pre-release') + <gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{ + __('Upcoming Release') }}</gl-badge> </h2> @@ -74,7 +74,7 @@ export default { <div class="append-right-4"> • - <span v-gl-tooltip.bottom :title="tooltipTitle(release.created_at)"> + <span v-gl-tooltip.bottom :title="tooltipTitle(release.released_at)"> {{ releasedTimeAgo }} </span> </div> diff --git a/app/assets/javascripts/reports/components/report_item.vue b/app/assets/javascripts/reports/components/report_item.vue index 01a30809e1a..2be9c37b00a 100644 --- a/app/assets/javascripts/reports/components/report_item.vue +++ b/app/assets/javascripts/reports/components/report_item.vue @@ -1,6 +1,6 @@ <script> import IssueStatusIcon from '~/reports/components/issue_status_icon.vue'; -import { components, componentNames } from '~/reports/components/issue_body'; +import { components, componentNames } from 'ee_else_ce/reports/components/issue_body'; export default { name: 'ReportItem', diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index f25cee9bb57..26493556063 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -1,10 +1,9 @@ <script> -import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui'; +import { GlTooltipDirective, GlLink, GlButton, GlLoadingIcon } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; import Icon from '../../vue_shared/components/icon.vue'; import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import TimeagoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; -import CommitPipelineStatus from '../../projects/tree/components/commit_pipeline_status_component.vue'; import CiIcon from '../../vue_shared/components/ci_icon.vue'; import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; import getRefMixin from '../mixins/get_ref'; @@ -16,11 +15,11 @@ export default { Icon, UserAvatarLink, TimeagoTooltip, - CommitPipelineStatus, ClipboardButton, CiIcon, GlLink, GlButton, + GlLoadingIcon, }, directives: { GlTooltip: GlTooltipDirective, @@ -39,7 +38,10 @@ export default { path: this.currentPath.replace(/^\//, ''), }; }, - update: data => data.project.repository.tree.commit, + update: data => data.project.repository.tree.lastCommit, + context: { + isSingleRequest: true, + }, }, }, props: { @@ -59,14 +61,14 @@ export default { computed: { statusTitle() { return sprintf(s__('Commits|Commit: %{commitText}'), { - commitText: this.commit.pipeline.detailedStatus.text, + commitText: this.commit.latestPipeline.detailedStatus.text, }); }, isLoading() { return this.$apollo.queries.commit.loading; }, showCommitId() { - return this.commit.id.substr(0, 8); + return this.commit.sha.substr(0, 8); }, }, methods: { @@ -78,68 +80,75 @@ export default { </script> <template> - <div v-if="!isLoading" class="info-well d-none d-sm-flex project-last-commit commit p-3"> - <user-avatar-link - v-if="commit.author" - :link-href="commit.author.webUrl" - :img-src="commit.author.avatarUrl" - :img-size="40" - class="avatar-cell" - /> - <div class="commit-detail flex-list"> - <div class="commit-content qa-commit-content"> - <gl-link :href="commit.webUrl" class="commit-row-message item-title"> - {{ commit.title }} - </gl-link> - <gl-button - v-if="commit.description" - :class="{ open: showDescription }" - :aria-label="__('Show commit description')" - class="text-expander" - @click="toggleShowDescription" - > - <icon name="ellipsis_h" /> - </gl-button> - <div class="committer"> + <div class="info-well d-none d-sm-flex project-last-commit commit p-3"> + <gl-loading-icon v-if="isLoading" size="md" class="mx-auto" /> + <template v-else> + <user-avatar-link + v-if="commit.author" + :link-href="commit.author.webUrl" + :img-src="commit.author.avatarUrl" + :img-size="40" + class="avatar-cell" + /> + <div class="commit-detail flex-list"> + <div class="commit-content qa-commit-content"> + <gl-link :href="commit.webUrl" class="commit-row-message item-title"> + {{ commit.title }} + </gl-link> + <gl-button + v-if="commit.description" + :class="{ open: showDescription }" + :aria-label="__('Show commit description')" + class="text-expander" + @click="toggleShowDescription" + > + <icon name="ellipsis_h" /> + </gl-button> + <div class="committer"> + <gl-link + v-if="commit.author" + :href="commit.author.webUrl" + class="commit-author-link js-user-link" + > + {{ commit.author.name }} + </gl-link> + authored + <timeago-tooltip :time="commit.authoredDate" tooltip-placement="bottom" /> + </div> + <pre + v-if="commit.description" + v-show="showDescription" + class="commit-row-description append-bottom-8" + > + {{ commit.description }} + </pre> + </div> + <div class="commit-actions flex-row"> <gl-link - v-if="commit.author" - :href="commit.author.webUrl" - class="commit-author-link js-user-link" + v-if="commit.latestPipeline" + v-gl-tooltip + :href="commit.latestPipeline.detailedStatus.detailsPath" + :title="statusTitle" + class="js-commit-pipeline" > - {{ commit.author.name }} + <ci-icon + :status="commit.latestPipeline.detailedStatus" + :size="24" + :aria-label="statusTitle" + /> </gl-link> - authored - <timeago-tooltip :time="commit.authoredDate" tooltip-placement="bottom" /> - </div> - <pre - v-if="commit.description" - v-show="showDescription" - class="commit-row-description append-bottom-8" - > - {{ commit.description }} - </pre> - </div> - <div class="commit-actions flex-row"> - <gl-link - v-if="commit.pipeline" - v-gl-tooltip - :href="commit.pipeline.detailedStatus.detailsPath" - :title="statusTitle" - class="js-commit-pipeline" - > - <ci-icon :status="commit.pipeline.detailedStatus" :size="24" :aria-label="statusTitle" /> - </gl-link> - <div class="commit-sha-group d-flex"> - <div class="label label-monospace monospace"> - {{ showCommitId }} + <div class="commit-sha-group d-flex"> + <div class="label label-monospace monospace"> + {{ showCommitId }} + </div> + <clipboard-button + :text="commit.sha" + :title="__('Copy commit SHA to clipboard')" + tooltip-placement="bottom" + /> </div> - <clipboard-button - :text="commit.id" - :title="__('Copy commit SHA to clipboard')" - tooltip-placement="bottom" - /> </div> </div> - </div> + </template> </div> </template> diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index c31e7fa71a2..3e060e9ecb6 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -110,9 +110,7 @@ export default { <component :is="linkComponent" :to="routerLinkTo" :href="url" class="str-truncated"> {{ fullPath }} </component> - <gl-badge v-if="lfsOid" variant="default" class="label-lfs ml-1"> - LFS - </gl-badge> + <gl-badge v-if="lfsOid" variant="default" class="label-lfs ml-1">LFS</gl-badge> <template v-if="isSubmodule"> @ <gl-link href="#" class="commit-sha">{{ shortSha }}</gl-link> </template> diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 6280977b05b..ea051eaa414 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -50,23 +50,19 @@ export default function setupVueRepositoryList() { }, }); - const commitEl = document.getElementById('js-last-commit'); - - if (commitEl) { - // eslint-disable-next-line no-new - new Vue({ - el: commitEl, - router, - apolloProvider, - render(h) { - return h(LastCommit, { - props: { - currentPath: this.$route.params.pathMatch, - }, - }); - }, - }); - } + // eslint-disable-next-line no-new + new Vue({ + el: document.getElementById('js-last-commit'), + router, + apolloProvider, + render(h) { + return h(LastCommit, { + props: { + currentPath: this.$route.params.pathMatch, + }, + }); + }, + }); return new Vue({ el, diff --git a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql index 90901f54d54..3bdfd979fa4 100644 --- a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql +++ b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql @@ -2,8 +2,8 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) { project(fullPath: $projectPath) { repository { tree(path: $path, ref: $ref) { - commit { - id + lastCommit { + sha title message webUrl @@ -13,7 +13,7 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) { avatarUrl webUrl } - pipeline { + latestPipeline { detailedStatus { detailsPath icon diff --git a/app/assets/javascripts/serverless/components/area.vue b/app/assets/javascripts/serverless/components/area.vue index 32c9d6eccb8..a1a8cd3acbd 100644 --- a/app/assets/javascripts/serverless/components/area.vue +++ b/app/assets/javascripts/serverless/components/area.vue @@ -4,6 +4,7 @@ import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; import dateFormat from 'dateformat'; import { X_INTERVAL } from '../constants'; import { validateGraphData } from '../utils'; +import { __ } from '~/locale'; let debouncedResize; @@ -42,7 +43,7 @@ export default { }, generateSeries() { return { - name: 'Invocations', + name: __('Invocations'), type: 'line', data: this.chartData.requests.map(data => [data.time, data.value]), symbolSize: 0, @@ -124,7 +125,9 @@ export default { <div class="prometheus-graph"> <div class="prometheus-graph-header"> <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5> - <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div> + <div ref="graphWidgets" class="prometheus-graph-widgets"> + <slot></slot> + </div> </div> <gl-area-chart ref="areaChart" @@ -135,12 +138,8 @@ export default { :width="width" :include-legend-avg-max="false" > - <template slot="tooltipTitle"> - {{ tooltipPopoverTitle }} - </template> - <template slot="tooltipContent"> - {{ tooltipPopoverContent }} - </template> + <template slot="tooltipTitle">{{ tooltipPopoverTitle }}</template> + <template slot="tooltipContent">{{ tooltipPopoverContent }}</template> </gl-area-chart> </div> </template> diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue index b8906cfca4e..d542dad8119 100644 --- a/app/assets/javascripts/serverless/components/function_details.vue +++ b/app/assets/javascripts/serverless/components/function_details.vue @@ -89,7 +89,9 @@ export default { }} </p> </div> - <div v-else><p>No pods loaded at this time.</p></div> + <div v-else> + <p>{{ s__('ServerlessDetails|No pods loaded at this time.') }}</p> + </div> <area-chart v-if="hasPrometheusData" :graph-data="graphData" :container-width="elWidth" /> <missing-prometheus diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index 94341050b86..9e66869515c 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -1,4 +1,5 @@ <script> +import { sprintf, s__ } from '~/locale'; import { mapState, mapActions, mapGetters } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; import FunctionRow from './function_row.vue'; @@ -37,6 +38,28 @@ export default { isInstalled() { return this.installed === true; }, + noServerlessConfigFile() { + return sprintf( + s__( + 'Serverless|Your repository does not have a corresponding %{startTag}serverless.yml%{endTag} file.', + ), + { startTag: '<code>', endTag: '</code>' }, + ); + }, + noGitlabYamlConfigured() { + return sprintf( + s__('Serverless|Your %{startTag}.gitlab-ci.yml%{endTag} file is not properly configured.'), + { startTag: '<code>', endTag: '</code>' }, + ); + }, + mismatchedServerlessFunctions() { + return sprintf( + s__( + "Serverless|The functions listed in the %{startTag}serverless.yml%{endTag} file don't match the namespace of your cluster.", + ), + { startTag: '<code>', endTag: '</code>' }, + ); + }, }, created() { this.fetchFunctions({ @@ -82,25 +105,29 @@ export default { <h4 class="state-title text-center">{{ s__('Serverless|No functions available') }}</h4> <p class="state-description"> {{ - s__(`Serverless|There is currently no function data available from Knative. - This could be for a variety of reasons including:`) + s__( + 'Serverless|There is currently no function data available from Knative. This could be for a variety of reasons including:', + ) }} </p> <ul> - <li>Your repository does not have a corresponding <code>serverless.yml</code> file.</li> - <li>Your <code>.gitlab-ci.yml</code> file is not properly configured.</li> <li> - The functions listed in the <code>serverless.yml</code> file don't match the namespace - of your cluster. + {{ noServerlessConfigFile }} + </li> + <li> + {{ noGitlabYamlConfigured }} + </li> + <li> + {{ mismatchedServerlessFunctions }} </li> - <li>The deploy job has not finished.</li> + <li>{{ s__('Serverless|The deploy job has not finished.') }}</li> </ul> <p> {{ - s__(`Serverless|If you believe none of these apply, please check - back later as the function data may be in the process of becoming - available.`) + s__( + 'Serverless|If you believe none of these apply, please check back later as the function data may be in the process of becoming available.', + ) }} </p> <div class="text-center"> diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 41386178a1e..a79da476890 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -162,7 +162,8 @@ export default { removeWIPPath: store.removeWIPPath, sourceBranchPath: store.sourceBranchPath, ciEnvironmentsStatusPath: store.ciEnvironmentsStatusPath, - statusPath: store.statusPath, + mergeRequestBasicPath: store.mergeRequestBasicPath, + mergeRequestWidgetPath: store.mergeRequestWidgetPath, mergeActionsContentPath: store.mergeActionsContentPath, rebasePath: store.rebasePath, }; diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js index 0bb70bfd658..1dae53039d5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js +++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js @@ -30,11 +30,11 @@ export default class MRWidgetService { } poll() { - return axios.get(`${this.endpoints.statusPath}?serializer=basic`); + return axios.get(this.endpoints.mergeRequestBasicPath); } checkStatus() { - return axios.get(`${this.endpoints.statusPath}?serializer=widget`); + return axios.get(this.endpoints.mergeRequestWidgetPath); } fetchMergeActionsContent() { diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index bfa3e7f4a59..581fee7477f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -86,7 +86,8 @@ export default class MergeRequestStore { this.mergePath = data.merge_path; this.ffOnlyEnabled = data.ff_only_enabled; this.shouldBeRebased = Boolean(data.should_be_rebased); - this.statusPath = data.status_path; + this.mergeRequestBasicPath = data.merge_request_basic_path; + this.mergeRequestWidgetPath = data.merge_request_widget_path; this.emailPatchesPath = data.email_patches_path; this.plainDiffPath = data.plain_diff_path; this.newBlobPath = data.new_blob_path; diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 1bfa91500cb..fe5289ff371 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -131,7 +131,7 @@ export default { </script> <template> - <div> + <div v-if="!file.moved"> <file-header v-if="file.isHeader" :path="file.path" /> <div v-else diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue index baed26a157c..af02b8969ee 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue @@ -39,7 +39,7 @@ export default { </script> <template> - <timeline-entry-item class="note being-posted fade-in-half"> + <timeline-entry-item class="note note-wrapper being-posted fade-in-half"> <div class="timeline-icon"> <user-avatar-link :link-href="getUserData.path" diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index a2f518cd24e..d5ef66af31a 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -11,10 +11,10 @@ // like a table or typography then make changes in the framework/ directory. // If you need to add unique style that should affect only one page - use pages/ // directory. -@import "../../../node_modules/at.js/dist/css/jquery.atwho"; -@import "../../../node_modules/pikaday/scss/pikaday"; -@import "../../../node_modules/dropzone/dist/basic"; -@import "../../../node_modules/select2/select2"; +@import "at.js/dist/css/jquery.atwho"; +@import "pikaday/scss/pikaday"; +@import "dropzone/dist/basic"; +@import "select2/select2"; // GitLab UI framework @import "framework"; diff --git a/app/assets/stylesheets/csslab.scss b/app/assets/stylesheets/csslab.scss index acaa41e2677..87c59cd42c0 100644 --- a/app/assets/stylesheets/csslab.scss +++ b/app/assets/stylesheets/csslab.scss @@ -1 +1 @@ -@import "../../../node_modules/@gitlab/csslab/dist/css/csslab-slim"; +@import "@gitlab/csslab/dist/css/csslab-slim"; diff --git a/app/assets/stylesheets/errors.scss b/app/assets/stylesheets/errors.scss index 8c32b6c8985..d287215096e 100644 --- a/app/assets/stylesheets/errors.scss +++ b/app/assets/stylesheets/errors.scss @@ -2,12 +2,12 @@ * This is a minimal stylesheet, meant to be used for error pages. */ @import 'framework/variables'; -@import '../../../node_modules/bootstrap/scss/functions'; -@import '../../../node_modules/bootstrap/scss/variables'; -@import '../../../node_modules/bootstrap/scss/mixins'; -@import '../../../node_modules/bootstrap/scss/reboot'; -@import '../../../node_modules/bootstrap/scss/buttons'; -@import '../../../node_modules/bootstrap/scss/forms'; +@import 'bootstrap/scss/functions'; +@import 'bootstrap/scss/variables'; +@import 'bootstrap/scss/mixins'; +@import 'bootstrap/scss/reboot'; +@import 'bootstrap/scss/buttons'; +@import 'bootstrap/scss/forms'; $body-color: #666; $header-color: #456; diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 14f4652e847..9b1d9d51f9c 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -2,7 +2,7 @@ @import 'framework/variables_overrides'; @import 'framework/mixins'; -@import '../../../node_modules/@gitlab/ui/scss/gitlab_ui'; +@import '@gitlab/ui/scss/gitlab_ui'; @import 'bootstrap_migration'; @import 'framework/layout'; diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 5e3652db48f..343cca96851 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -92,9 +92,20 @@ width: 400px; } - &.is-expandable { - .board-header { - cursor: pointer; + .board-title-caret { + cursor: pointer; + border-radius: $border-radius-default; + padding: 4px; + + &:hover { + background-color: $gray-dark; + transition: background-color 0.1s linear; + } + } + + &:not(.is-collapsed) { + .board-title-caret { + margin: 0 $gl-padding-4 0 -10px; } } @@ -102,20 +113,51 @@ width: 50px; .board-title { - > span { - width: 100%; - margin-top: -12px; + flex-direction: column; + height: 100%; + padding: $gl-padding-8 0; + } + + .board-title-caret { + margin-top: 1px; + } + + .user-avatar-link, + .milestone-icon { + margin-top: $gl-padding-8; + transform: rotate(90deg); + } + + .board-title-text { + flex-grow: 0; + margin: $gl-padding-8 0; + + .board-title-main-text { display: block; - transform: rotate(90deg) translate(35px, 0); - overflow: initial; + } + + .board-title-sub-text { + display: none; } } - .board-title-expandable-toggle { - position: absolute; - top: 50%; - left: 50%; - margin-left: -10px; + .issue-count-badge { + border: 0; + white-space: nowrap; + } + + .board-title-text > span, + .issue-count-badge > span { + height: 16px; + + // Force the height to be equal to the parent's width while centering the contents. + // The contents *should* be about 16 px. + // We do this because the flow of elements isn't affected by the rotate transform, so we must ensure that a + // rotated element has square dimensions so it won't overlap with its siblings. + margin: calc(50% - 8px) 0; + + transform: rotate(90deg); + transform-origin: center; } } } @@ -152,12 +194,14 @@ } .board-title { + align-items: center; font-size: 1em; border-bottom: 1px solid $border-color; + padding: $gl-padding-8 $gl-padding; } .board-title-text { - margin: $gl-vert-padding auto $gl-vert-padding 0; + flex-grow: 1; } .board-delete { diff --git a/app/assets/stylesheets/pages/container_registry.scss b/app/assets/stylesheets/pages/container_registry.scss index dfff3e15556..cca5214a508 100644 --- a/app/assets/stylesheets/pages/container_registry.scss +++ b/app/assets/stylesheets/pages/container_registry.scss @@ -2,6 +2,12 @@ * Container Registry */ +.container-message { + pre { + white-space: pre-line; + } +} + .container-image { border-bottom: 1px solid $white-normal; } diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index d2d35d91e0b..623c44e062f 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -1093,6 +1093,17 @@ table.code { line-height: 0; } +.discussion-collapsible { + margin: 0 $gl-padding $gl-padding 71px; +} + +.parallel { + .discussion-collapsible { + margin: $gl-padding; + margin-top: 0; + } +} + @media (max-width: map-get($grid-breakpoints, md)-1) { .diffs .files { @include fixed-width-container; @@ -1110,6 +1121,11 @@ table.code { padding-right: 0; } } + + .discussion-collapsible { + margin: $gl-padding; + margin-top: 0; + } } .image-diff-overlay, diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 824edb2869f..7bd1a4138e4 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -134,6 +134,16 @@ $note-form-margin-left: 72px; } } + .discussion-toggle-replies { + border-top: 0; + border-radius: 4px 4px 0 0; + + &.collapsed { + border: 0; + border-radius: 4px; + } + } + .note-created-ago, .note-updated-at { white-space: normal; @@ -462,6 +472,14 @@ $note-form-margin-left: 72px; position: relative; } + .notes-content .discussion-notes.diff-discussions { + border-bottom: 1px solid $border-color; + + &:nth-last-child(1) { + border-bottom: 0; + } + } + .notes_holder { font-family: $regular-font; @@ -517,6 +535,17 @@ $note-form-margin-left: 72px; .discussion-reply-holder { border-radius: 0 0 $border-radius-default $border-radius-default; position: relative; + + .discussion-form { + width: 100%; + background-color: $gray-light; + padding: 0; + } + + .disabled-comment { + padding: $gl-vert-padding 0; + width: 100%; + } } } @@ -657,6 +686,10 @@ $note-form-margin-left: 72px; margin-left: -1px; } + .btn-group > .discussion-create-issue-btn { + margin-left: -2px; + } + svg { height: 15px; } diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 42634bf611e..a570da61d54 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -64,7 +64,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController private def set_application_setting - @application_setting = Gitlab::CurrentSettings.current_application_settings + @application_setting = ApplicationSetting.current_without_cache end def whitelist_query_limiting diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index 65d14781d92..d43f5393ecc 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -3,6 +3,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController include ParamsBackwardCompatibility include RendersMemberAccess + include OnboardingExperimentHelper prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) } before_action :set_non_archived_param diff --git a/app/controllers/groups/clusters_controller.rb b/app/controllers/groups/clusters_controller.rb index b846fb21266..92602fd8096 100644 --- a/app/controllers/groups/clusters_controller.rb +++ b/app/controllers/groups/clusters_controller.rb @@ -4,7 +4,6 @@ class Groups::ClustersController < Clusters::ClustersController include ControllerWithCrossProjectAccessCheck prepend_before_action :group - prepend_before_action :check_group_clusters_feature_flag! requires_cross_project_access layout 'group' @@ -18,12 +17,4 @@ class Groups::ClustersController < Clusters::ClustersController def group @group ||= find_routable!(Group, params[:group_id] || params[:id]) end - - def check_group_clusters_feature_flag! - render_404 unless group_clusters_enabled? - end - - def group_clusters_enabled? - group.group_clusters_enabled? - end end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 316da8f129d..797833e3f91 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -201,8 +201,7 @@ class GroupsController < Groups::ApplicationController params[:sort] ||= 'latest_activity_desc' options = {} - options[:only_owned] = true if params[:shared] == '0' - options[:only_shared] = true if params[:shared] == '1' + options[:include_subgroups] = true @projects = GroupProjectsFinder.new(params: params, group: group, options: options, current_user: current_user) .execute diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index fc708400657..d77f64a84f5 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -25,15 +25,6 @@ class Projects::BranchesController < Projects::ApplicationController @refs_pipelines = @project.ci_pipelines.latest_successful_for_refs(@branches.map(&:name)) @merged_branch_names = repository.merged_branch_names(@branches.map(&:name)) - # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/48097 - Gitlab::GitalyClient.allow_n_plus_1_calls do - @max_commits = @branches.reduce(0) do |memo, branch| - diverging_commit_counts = repository.diverging_commit_counts(branch) - [memo, diverging_commit_counts.values_at(:behind, :ahead, :distance)] - .flatten.compact.max - end - end - # https://gitlab.com/gitlab-org/gitlab-ce/issues/48097 Gitlab::GitalyClient.allow_n_plus_1_calls do render @@ -51,6 +42,19 @@ class Projects::BranchesController < Projects::ApplicationController @branches = @repository.recent_branches end + def diverging_commit_counts + respond_to do |format| + format.json do + service = Branches::DivergingCommitCountsService.new(repository) + branches = BranchesFinder.new(repository, params.permit(names: [])).execute + + Gitlab::GitalyClient.allow_n_plus_1_calls do + render json: branches.to_h { |branch| [branch.name, service.call(branch)] } + end + end + end + end + # rubocop: disable CodeReuse/ActiveRecord def create branch_name = strip_tags(sanitize(params[:branch_name])) @@ -64,8 +68,9 @@ class Projects::BranchesController < Projects::ApplicationController success = (result[:status] == :success) if params[:issue_iid] && success - issue = IssuesFinder.new(current_user, project_id: @project.id).find_by(iid: params[:issue_iid]) - SystemNoteService.new_issue_branch(issue, @project, current_user, branch_name) if issue + target_project = confidential_issue_project || @project + issue = IssuesFinder.new(current_user, project_id: target_project.id).find_by(iid: params[:issue_iid]) + SystemNoteService.new_issue_branch(issue, target_project, current_user, branch_name, branch_project: @project) if issue end respond_to do |format| @@ -162,4 +167,15 @@ class Projects::BranchesController < Projects::ApplicationController @branches = Kaminari.paginate_array(@branches).page(params[:page]) end end + + def confidential_issue_project + return unless Feature.enabled?(:create_confidential_merge_request, @project) + return if params[:confidential_issue_project_id].blank? + + confidential_issue_project = Project.find(params[:confidential_issue_project_id]) + + return unless can?(current_user, :update_issue, confidential_issue_project) + + confidential_issue_project + end end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index f221f0363d3..e275b417784 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -172,6 +172,7 @@ class Projects::IssuesController < Projects::ApplicationController def create_merge_request create_params = params.slice(:branch_name, :ref).merge(issue_iid: issue.iid) + create_params[:target_project_id] = params[:target_project_id] if Feature.enabled?(:create_confidential_merge_request, @project) result = ::MergeRequests::CreateFromIssueService.new(project, current_user, create_params).execute if result[:status] == :success diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index f2a6268b3e9..dcc272aecff 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -51,4 +51,11 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont Ci::Pipeline.none end end + + def close_merge_request_if_no_source_project + return if @merge_request.source_project + return unless @merge_request.open? + + @merge_request.close + end end diff --git a/app/controllers/projects/merge_requests/content_controller.rb b/app/controllers/projects/merge_requests/content_controller.rb new file mode 100644 index 00000000000..6e026b83ee3 --- /dev/null +++ b/app/controllers/projects/merge_requests/content_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class Projects::MergeRequests::ContentController < Projects::MergeRequests::ApplicationController + # @merge_request.check_mergeability is not executed here since + # widget serializer calls it via mergeable? method + # but we might want to call @merge_request.check_mergeability + # for other types of serialization + + before_action :close_merge_request_if_no_source_project + around_action :allow_gitaly_ref_name_caching + + def widget + respond_to do |format| + format.json do + Gitlab::PollingInterval.set_header(response, interval: 10_000) + + serializer = MergeRequestSerializer.new(current_user: current_user, project: merge_request.project) + render json: serializer.represent(merge_request, serializer: 'widget') + end + end + end +end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index fc37ce1dbc4..7ee8e0ea8f8 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -235,12 +235,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo params[:auto_merge_strategy].present? || params[:merge_when_pipeline_succeeds].present? end - def close_merge_request_if_no_source_project - if !@merge_request.source_project && @merge_request.open? - @merge_request.close - end - end - private def ci_environments_status_on_merge_result? diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb index 6d60117c37d..e205e2fd4f8 100644 --- a/app/controllers/projects/registry/repositories_controller.rb +++ b/app/controllers/projects/registry/repositories_controller.rb @@ -46,6 +46,8 @@ module Projects repository.save! if repository.has_tags? end end + rescue ContainerRegistry::Path::InvalidRegistryPathError + @character_error = true end end end diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb index ac3004d069f..bc2ce15286f 100644 --- a/app/controllers/projects/settings/repository_controller.rb +++ b/app/controllers/projects/settings/repository_controller.rb @@ -99,7 +99,7 @@ module Projects end def deploy_token_params - params.require(:deploy_token).permit(:name, :expires_at, :read_repository, :read_registry) + params.require(:deploy_token).permit(:name, :expires_at, :read_repository, :read_registry, :username) end end end diff --git a/app/finders/branches_finder.rb b/app/finders/branches_finder.rb index 45d5591e81b..b462c8053fa 100644 --- a/app/finders/branches_finder.rb +++ b/app/finders/branches_finder.rb @@ -9,6 +9,7 @@ class BranchesFinder def execute branches = repository.branches_sorted_by(sort) branches = by_search(branches) + branches = by_names(branches) branches end @@ -16,6 +17,10 @@ class BranchesFinder attr_reader :repository, :params + def names + @params[:names].presence + end + def search @params[:search].presence end @@ -59,4 +64,13 @@ class BranchesFinder def find_exact_match_index(matches, term) matches.index { |branch| branch.name.casecmp(term) == 0 } end + + def by_names(branches) + return branches unless names + + branch_names = names.to_set + branches.filter do |branch| + branch_names.include?(branch.name) + end + end end diff --git a/app/graphql/mutations/.keep b/app/graphql/mutations/.keep deleted file mode 100644 index e69de29bb2d..00000000000 --- a/app/graphql/mutations/.keep +++ /dev/null diff --git a/app/graphql/mutations/award_emojis/add.rb b/app/graphql/mutations/award_emojis/add.rb new file mode 100644 index 00000000000..8e050dd6d29 --- /dev/null +++ b/app/graphql/mutations/award_emojis/add.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Mutations + module AwardEmojis + class Add < Base + graphql_name 'AddAwardEmoji' + + def resolve(args) + awardable = authorized_find!(id: args[:awardable_id]) + + check_object_is_awardable!(awardable) + + # TODO this will be handled by AwardEmoji::AddService + # See https://gitlab.com/gitlab-org/gitlab-ce/issues/63372 and + # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/29782 + award = awardable.create_award_emoji(args[:name], current_user) + + { + award_emoji: (award if award.persisted?), + errors: errors_on_object(award) + } + end + end + end +end diff --git a/app/graphql/mutations/award_emojis/base.rb b/app/graphql/mutations/award_emojis/base.rb new file mode 100644 index 00000000000..d868db84f9d --- /dev/null +++ b/app/graphql/mutations/award_emojis/base.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Mutations + module AwardEmojis + class Base < BaseMutation + include Gitlab::Graphql::Authorize::AuthorizeResource + + authorize :award_emoji + + argument :awardable_id, + GraphQL::ID_TYPE, + required: true, + description: 'The global id of the awardable resource' + + argument :name, + GraphQL::STRING_TYPE, + required: true, + description: copy_field_description(Types::AwardEmojis::AwardEmojiType, :name) + + field :award_emoji, + Types::AwardEmojis::AwardEmojiType, + null: true, + description: 'The award emoji after mutation' + + private + + def find_object(id:) + GitlabSchema.object_from_id(id) + end + + # Called by mutations methods after performing an authorization check + # of an awardable object. + def check_object_is_awardable!(object) + unless object.is_a?(Awardable) && object.emoji_awardable? + raise Gitlab::Graphql::Errors::ResourceNotAvailable, + 'Cannot award emoji to this resource' + end + end + end + end +end diff --git a/app/graphql/mutations/award_emojis/remove.rb b/app/graphql/mutations/award_emojis/remove.rb new file mode 100644 index 00000000000..3ba85e445b8 --- /dev/null +++ b/app/graphql/mutations/award_emojis/remove.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Mutations + module AwardEmojis + class Remove < Base + graphql_name 'RemoveAwardEmoji' + + def resolve(args) + awardable = authorized_find!(id: args[:awardable_id]) + + check_object_is_awardable!(awardable) + + # TODO this check can be removed once AwardEmoji services are available. + # See https://gitlab.com/gitlab-org/gitlab-ce/issues/63372 and + # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/29782 + unless awardable.awarded_emoji?(args[:name], current_user) + raise Gitlab::Graphql::Errors::ResourceNotAvailable, + 'You have not awarded emoji of type name to the awardable' + end + + # TODO this will be handled by AwardEmoji::DestroyService + # See https://gitlab.com/gitlab-org/gitlab-ce/issues/63372 and + # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/29782 + awardable.remove_award_emoji(args[:name], current_user) + + { + # Mutation response is always a `nil` award_emoji + errors: [] + } + end + end + end +end diff --git a/app/graphql/mutations/award_emojis/toggle.rb b/app/graphql/mutations/award_emojis/toggle.rb new file mode 100644 index 00000000000..c03902e8035 --- /dev/null +++ b/app/graphql/mutations/award_emojis/toggle.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Mutations + module AwardEmojis + class Toggle < Base + graphql_name 'ToggleAwardEmoji' + + field :toggledOn, + GraphQL::BOOLEAN_TYPE, + null: false, + description: 'True when the emoji was awarded, false when it was removed' + + def resolve(args) + awardable = authorized_find!(id: args[:awardable_id]) + + check_object_is_awardable!(awardable) + + # TODO this will be handled by AwardEmoji::ToggleService + # See https://gitlab.com/gitlab-org/gitlab-ce/issues/63372 and + # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/29782 + award = awardable.toggle_award_emoji(args[:name], current_user) + + # Destroy returns a collection :( + award = award.first if award.is_a?(Array) + + errors = errors_on_object(award) + + toggled_on = awardable.awarded_emoji?(args[:name], current_user) + + { + # For consistency with the AwardEmojis::Remove mutation, only return + # the AwardEmoji if it was created and not destroyed + award_emoji: (award if toggled_on), + errors: errors, + toggled_on: toggled_on + } + end + end + end +end diff --git a/app/graphql/mutations/base_mutation.rb b/app/graphql/mutations/base_mutation.rb index eb03dfe1624..08d2a1f18a3 100644 --- a/app/graphql/mutations/base_mutation.rb +++ b/app/graphql/mutations/base_mutation.rb @@ -2,6 +2,8 @@ module Mutations class BaseMutation < GraphQL::Schema::RelayClassicMutation + prepend Gitlab::Graphql::CopyFieldDescription + field :errors, [GraphQL::STRING_TYPE], null: false, description: "Reasons why the mutation failed." @@ -9,5 +11,10 @@ module Mutations def current_user context[:current_user] end + + # Returns Array of errors on an ActiveRecord object + def errors_on_object(record) + record.errors.full_messages + end end end diff --git a/app/graphql/types/award_emojis/award_emoji_type.rb b/app/graphql/types/award_emojis/award_emoji_type.rb new file mode 100644 index 00000000000..8daf699a112 --- /dev/null +++ b/app/graphql/types/award_emojis/award_emoji_type.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Types + module AwardEmojis + class AwardEmojiType < BaseObject + graphql_name 'AwardEmoji' + + authorize :read_emoji + + present_using AwardEmojiPresenter + + field :name, + GraphQL::STRING_TYPE, + null: false, + description: 'The emoji name' + + field :description, + GraphQL::STRING_TYPE, + null: false, + description: 'The emoji description' + + field :unicode, + GraphQL::STRING_TYPE, + null: false, + description: 'The emoji in unicode' + + field :emoji, + GraphQL::STRING_TYPE, + null: false, + description: 'The emoji as an icon' + + field :unicode_version, + GraphQL::STRING_TYPE, + null: false, + description: 'The unicode version for this emoji' + + field :user, + Types::UserType, + null: false, + description: 'The user who awarded the emoji', + resolve: -> (award_emoji, _args, _context) { + Gitlab::Graphql::Loaders::BatchModelLoader.new(User, award_emoji.user_id).find + } + end + end +end diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb new file mode 100644 index 00000000000..d73dd73affd --- /dev/null +++ b/app/graphql/types/commit_type.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Types + class CommitType < BaseObject + graphql_name 'Commit' + + authorize :download_code + + present_using CommitPresenter + + field :id, type: GraphQL::ID_TYPE, null: false + field :sha, type: GraphQL::STRING_TYPE, null: false + field :title, type: GraphQL::STRING_TYPE, null: true + field :description, type: GraphQL::STRING_TYPE, null: true + field :message, type: GraphQL::STRING_TYPE, null: true + field :authored_date, type: Types::TimeType, null: true + field :web_url, type: GraphQL::STRING_TYPE, null: false + + # models/commit lazy loads the author by email + field :author, type: Types::UserType, null: true + + field :latest_pipeline, + type: Types::Ci::PipelineType, + null: true, + description: "Latest pipeline for this commit", + resolve: -> (obj, ctx, args) do + Gitlab::Graphql::Loaders::PipelineForShaLoader.new(obj.project, obj.sha).find_last + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 2b4ef299296..6ef1d816b7c 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -6,6 +6,9 @@ module Types graphql_name "Mutation" + mount_mutation Mutations::AwardEmojis::Add + mount_mutation Mutations::AwardEmojis::Remove + mount_mutation Mutations::AwardEmojis::Toggle mount_mutation Mutations::MergeRequests::SetWip end end diff --git a/app/graphql/types/tree/tree_type.rb b/app/graphql/types/tree/tree_type.rb index e7644b071d3..b947713074e 100644 --- a/app/graphql/types/tree/tree_type.rb +++ b/app/graphql/types/tree/tree_type.rb @@ -6,6 +6,11 @@ module Types class TreeType < BaseObject graphql_name 'Tree' + # Complexity 10 as it triggers a Gitaly call on each render + field :last_commit, Types::CommitType, null: true, complexity: 10, resolve: -> (tree, args, ctx) do + tree.repository.last_commit_for_path(tree.sha, tree.path) + end + field :trees, Types::Tree::TreeEntryType.connection_type, null: false, resolve: -> (obj, args, ctx) do Gitlab::Graphql::Representation::TreeEntry.decorate(obj.trees, obj.repository) end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index aaaa954047f..a7a4e945a99 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -69,7 +69,7 @@ module ApplicationSettingsHelper # toggle button effect. def import_sources_checkboxes(help_block_id, options = {}) Gitlab::ImportSources.options.map do |name, source| - checked = Gitlab::CurrentSettings.import_sources.include?(source) + checked = @application_setting.import_sources.include?(source) css_class = checked ? 'active' : '' checkbox_name = 'application_setting[import_sources][]' @@ -85,7 +85,7 @@ module ApplicationSettingsHelper def oauth_providers_checkboxes button_based_providers.map do |source| - disabled = Gitlab::CurrentSettings.disabled_oauth_sign_in_sources.include?(source.to_s) + disabled = @application_setting.disabled_oauth_sign_in_sources.include?(source.to_s) css_class = ['btn'] css_class << 'active' unless disabled checkbox_name = 'application_setting[enabled_oauth_sign_in_sources][]' diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index a3f53ca8dd6..5aed7e313e6 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -142,7 +142,7 @@ module GroupsHelper can?(current_user, "read_group_#{resource}".to_sym, @group) end - if can?(current_user, :read_cluster, @group) && @group.group_clusters_enabled? + if can?(current_user, :read_cluster, @group) links << :kubernetes end diff --git a/app/helpers/onboarding_experiment_helper.rb b/app/helpers/onboarding_experiment_helper.rb new file mode 100644 index 00000000000..ad49d333d7a --- /dev/null +++ b/app/helpers/onboarding_experiment_helper.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module OnboardingExperimentHelper + def allow_access_to_onboarding? + ::Gitlab.com? && Feature.enabled?(:user_onboarding) + end +end diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb index 506c8d251b7..04db1980b99 100644 --- a/app/mailers/emails/notes.rb +++ b/app/mailers/emails/notes.rb @@ -51,7 +51,7 @@ module Emails def note_thread_options(recipient_id) { from: sender(@note.author_id), - to: recipient(recipient_id, @group), + to: recipient(recipient_id, @project&.group || @group), subject: subject("#{@note.noteable.title} (#{@note.noteable.reference_link_text})") } end @@ -60,7 +60,7 @@ module Emails # `note_id` is a `Note` when originating in `NotifyPreview` @note = note_id.is_a?(Note) ? note_id : Note.find(note_id) @project = @note.project - @group = @project.try(:group) || @note.noteable.try(:group) + @group = @note.noteable.try(:group) if (@project || @group) && @note.persisted? @sent_notification = SentNotification.record_note(@note, recipient_id, reply_key) diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index cd645850af3..8e558487c1c 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -23,11 +23,6 @@ class ApplicationSetting < ApplicationRecord serialize :domain_blacklist, Array # rubocop:disable Cop/ActiveRecordSerialize serialize :repository_storages # rubocop:disable Cop/ActiveRecordSerialize - ignore_column :circuitbreaker_failure_count_threshold - ignore_column :circuitbreaker_failure_reset_time - ignore_column :circuitbreaker_storage_timeout - ignore_column :circuitbreaker_access_retries - ignore_column :circuitbreaker_check_interval ignore_column :koding_url ignore_column :koding_enabled ignore_column :sentry_enabled @@ -277,4 +272,12 @@ class ApplicationSetting < ApplicationRecord # We already have an ApplicationSetting record, so just return it. current_without_cache end + + # By default, the backend is Rails.cache, which uses + # ActiveSupport::Cache::RedisStore. Since loading ApplicationSetting + # can cause a significant amount of load on Redis, let's cache it in + # memory. + def self.cache_backend + Gitlab::ThreadMemoryCache.cache_backend + end end diff --git a/app/models/board.rb b/app/models/board.rb index e08db764f65..50b6ca9b70f 100644 --- a/app/models/board.rb +++ b/app/models/board.rb @@ -4,11 +4,14 @@ class Board < ApplicationRecord belongs_to :group belongs_to :project - has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent + has_many :lists, -> { ordered }, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent + has_many :destroyable_lists, -> { destroyable.ordered }, class_name: "List" validates :project, presence: true, if: :project_needed? validates :group, presence: true, unless: :project + scope :with_associations, -> { preload(:destroyable_lists) } + def project_needed? !group end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 3727a9861aa..fd5aa216174 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -295,6 +295,11 @@ module Ci end end + def self.latest_for_shas(shas) + max_id_per_sha = for_sha(shas).group(:sha).select("max(id)") + where(id: max_id_per_sha) + end + def self.latest_successful_ids_per_project success.group(:project_id).select('max(id) as id') end diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb index a1023f44049..1430b82c2f2 100644 --- a/app/models/clusters/applications/ingress.rb +++ b/app/models/clusters/applications/ingress.rb @@ -35,11 +35,8 @@ module Clusters 'stable/nginx-ingress' end - # We will implement this in future MRs. - # Basically we need to check all dependent applications are not installed - # first. def allowed_to_uninstall? - false + external_ip_or_hostname? && application_jupyter_nil_or_installable? end def install_command @@ -52,6 +49,10 @@ module Clusters ) end + def external_ip_or_hostname? + external_ip.present? || external_hostname.present? + end + def schedule_status_update return unless installed? return if external_ip @@ -63,6 +64,12 @@ module Clusters def ingress_service cluster.kubeclient.get_service('ingress-nginx-ingress-controller', Gitlab::Kubernetes::Helm::NAMESPACE) end + + private + + def application_jupyter_nil_or_installable? + cluster.application_jupyter.nil? || cluster.application_jupyter&.installable? + end end end end diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb index 4aaa1f941e5..9ede0615fa3 100644 --- a/app/models/clusters/applications/jupyter.rb +++ b/app/models/clusters/applications/jupyter.rb @@ -23,9 +23,7 @@ module Clusters return unless cluster&.application_ingress_available? ingress = cluster.application_ingress - if ingress.external_ip || ingress.external_hostname - self.status = 'installable' - end + self.status = 'installable' if ingress.external_ip_or_hostname? end def chart @@ -40,12 +38,6 @@ module Clusters content_values.to_yaml end - # Will be addressed in future MRs - # We need to investigate and document what will be permanently deleted. - def allowed_to_uninstall? - false - end - def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index a6b7617b830..805c8a73f8c 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -49,14 +49,6 @@ module Clusters ) end - def uninstall_command - Gitlab::Kubernetes::Helm::DeleteCommand.new( - name: name, - rbac: cluster.platform_kubernetes_rbac?, - files: files - ) - end - def upgrade_command(values) ::Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, diff --git a/app/models/clusters/instance.rb b/app/models/clusters/instance.rb index d8a888d53ba..f21dbdf7f26 100644 --- a/app/models/clusters/instance.rb +++ b/app/models/clusters/instance.rb @@ -9,9 +9,5 @@ module Clusters def feature_available?(feature) ::Feature.enabled?(feature, default_enabled: true) end - - def self.enabled? - ::Feature.enabled?(:instance_clusters, default_enabled: true) - end end end diff --git a/app/models/concerns/cacheable_attributes.rb b/app/models/concerns/cacheable_attributes.rb index 3d60f6924c1..8cbf4bcfaf7 100644 --- a/app/models/concerns/cacheable_attributes.rb +++ b/app/models/concerns/cacheable_attributes.rb @@ -36,7 +36,7 @@ module CacheableAttributes end def retrieve_from_cache - record = Rails.cache.read(cache_key) + record = cache_backend.read(cache_key) ensure_cache_setup if record.present? record @@ -58,7 +58,7 @@ module CacheableAttributes end def expire - Rails.cache.delete(cache_key) + cache_backend.delete(cache_key) rescue # Gracefully handle when Redis is not available. For example, # omnibus may fail here during gitlab:assets:compile. @@ -69,9 +69,13 @@ module CacheableAttributes # to be loaded when read from cache: https://github.com/rails/rails/issues/27348 define_attribute_methods end + + def cache_backend + Rails.cache + end end def cache! - Rails.cache.write(self.class.cache_key, self, expires_in: 1.minute) + self.class.cache_backend.write(self.class.cache_key, self, expires_in: 1.minute) end end diff --git a/app/models/concerns/deployable.rb b/app/models/concerns/deployable.rb index bc12b06b5af..957b72f3721 100644 --- a/app/models/concerns/deployable.rb +++ b/app/models/concerns/deployable.rb @@ -18,6 +18,7 @@ module Deployable return unless environment.persisted? create_deployment!( + cluster_id: environment.deployment_platform&.cluster_id, project_id: environment.project_id, environment: environment, ref: ref, diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb index 1bd8a799f0d..5a358ae2ef6 100644 --- a/app/models/concerns/deployment_platform.rb +++ b/app/models/concerns/deployment_platform.rb @@ -13,8 +13,8 @@ module DeploymentPlatform def find_deployment_platform(environment) find_cluster_platform_kubernetes(environment: environment) || - find_group_cluster_platform_kubernetes_with_feature_guard(environment: environment) || - find_instance_cluster_platform_kubernetes_with_feature_guard(environment: environment) + find_group_cluster_platform_kubernetes(environment: environment) || + find_instance_cluster_platform_kubernetes(environment: environment) end # EE would override this and utilize environment argument @@ -23,24 +23,12 @@ module DeploymentPlatform .last&.platform_kubernetes end - def find_group_cluster_platform_kubernetes_with_feature_guard(environment: nil) - return unless group_clusters_enabled? - - find_group_cluster_platform_kubernetes(environment: environment) - end - # EE would override this and utilize environment argument def find_group_cluster_platform_kubernetes(environment: nil) Clusters::Cluster.enabled.default_environment.ancestor_clusters_for_clusterable(self) .first&.platform_kubernetes end - def find_instance_cluster_platform_kubernetes_with_feature_guard(environment: nil) - return unless Clusters::Instance.enabled? - - find_instance_cluster_platform_kubernetes(environment: environment) - end - # EE would override this and utilize environment argument def find_instance_cluster_platform_kubernetes(environment: nil) Clusters::Instance.new.clusters.enabled.default_environment diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb index 46d2c345758..22b6b1d720c 100644 --- a/app/models/concerns/relative_positioning.rb +++ b/app/models/concerns/relative_positioning.rb @@ -25,7 +25,7 @@ module RelativePositioning relative_position = position_between(max_relative_position, MAX_POSITION) object.relative_position = relative_position max_relative_position = relative_position - object.save + object.save(touch: false) end end end @@ -159,7 +159,7 @@ module RelativePositioning def save_positionable_neighbours return unless @positionable_neighbours - status = @positionable_neighbours.all?(&:save) + status = @positionable_neighbours.all? { |issue| issue.save(touch: false) } @positionable_neighbours = nil status diff --git a/app/models/concerns/update_project_statistics.rb b/app/models/concerns/update_project_statistics.rb index 1f881249322..570a735973f 100644 --- a/app/models/concerns/update_project_statistics.rb +++ b/app/models/concerns/update_project_statistics.rb @@ -19,9 +19,9 @@ # # - `statistic_attribute` must be an ActiveRecord attribute # - The model must implement `project` and `project_id`. i.e. direct Project relationship or delegation -# module UpdateProjectStatistics extend ActiveSupport::Concern + include AfterCommitQueue class_methods do attr_reader :project_statistics_name, :statistic_attribute @@ -31,7 +31,6 @@ module UpdateProjectStatistics # # - project_statistics_name: A column of `ProjectStatistics` to update # - statistic_attribute: An attribute of the current model, default to `size` - # def update_project_statistics(project_statistics_name:, statistic_attribute: :size) @project_statistics_name = project_statistics_name @statistic_attribute = statistic_attribute @@ -51,6 +50,7 @@ module UpdateProjectStatistics delta = read_attribute(attr).to_i - attribute_before_last_save(attr).to_i update_project_statistics(delta) + schedule_namespace_aggregation_worker end def update_project_statistics_attribute_changed? @@ -59,6 +59,8 @@ module UpdateProjectStatistics def update_project_statistics_after_destroy update_project_statistics(-read_attribute(self.class.statistic_attribute).to_i) + + schedule_namespace_aggregation_worker end def project_destroyed? @@ -68,5 +70,18 @@ module UpdateProjectStatistics def update_project_statistics(delta) ProjectStatistics.increment_statistic(project_id, self.class.project_statistics_name, delta) end + + def schedule_namespace_aggregation_worker + run_after_commit do + next unless schedule_aggregation_worker? + + Namespaces::ScheduleAggregationWorker.perform_async(project.namespace_id) + end + end + + def schedule_aggregation_worker? + !project.nil? && + Feature.enabled?(:update_statistics_namespace, project.root_ancestor) + end end end diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb index b0e570f52ba..33f0be91632 100644 --- a/app/models/deploy_token.rb +++ b/app/models/deploy_token.rb @@ -16,6 +16,14 @@ class DeployToken < ApplicationRecord has_many :projects, through: :project_deploy_tokens validate :ensure_at_least_one_scope + validates :username, + length: { maximum: 255 }, + allow_nil: true, + format: { + with: /\A[a-zA-Z0-9\.\+_-]+\z/, + message: "can contain only letters, digits, '_', '-', '+', and '.'" + } + before_save :ensure_token accepts_nested_attributes_for :project_deploy_tokens @@ -39,7 +47,7 @@ class DeployToken < ApplicationRecord end def username - "gitlab+deploy-token-#{id}" + super || default_username end def has_access_to?(requested_project) @@ -75,4 +83,8 @@ class DeployToken < ApplicationRecord def ensure_at_least_one_scope errors.add(:base, "Scopes can't be blank") unless read_repository || read_registry end + + def default_username + "gitlab+deploy-token-#{id}" if persisted? + end end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index f0fa5974787..a8f5642f726 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -7,6 +7,7 @@ class Deployment < ApplicationRecord belongs_to :project, required: true belongs_to :environment, required: true + belongs_to :cluster, class_name: 'Clusters::Cluster', optional: true belongs_to :user belongs_to :deployable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations @@ -196,7 +197,22 @@ class Deployment < ApplicationRecord private def prometheus_adapter - environment.prometheus_adapter + service = project.find_or_initialize_service('prometheus') + + if service.can_query? + service + else + cluster_prometheus + end + end + + # TODO remove fallback case to deployment_platform_cluster. + # Otherwise we will continue to pay the performance penalty described in + # https://gitlab.com/gitlab-org/gitlab-ce/issues/63475 + def cluster_prometheus + cluster_with_fallback = cluster || deployment_platform_cluster + + cluster_with_fallback.application_prometheus if cluster_with_fallback&.application_prometheus_available? end def ref_path diff --git a/app/models/group.rb b/app/models/group.rb index dbec211935d..8e89c7ecfb1 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -410,10 +410,6 @@ class Group < Namespace ensure_runners_token! end - def group_clusters_enabled? - Feature.enabled?(:group_clusters, root_ancestor, default_enabled: true) - end - def project_creation_level super || ::Gitlab::CurrentSettings.default_project_creation end diff --git a/app/models/list.rb b/app/models/list.rb index 17b1a8510cf..d28a9bda82d 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -16,6 +16,7 @@ class List < ApplicationRecord scope :destroyable, -> { where(list_type: list_types.slice(*destroyable_types).values) } scope :movable, -> { where(list_type: list_types.slice(*movable_types).values) } scope :preload_associations, -> { preload(:board, :label) } + scope :ordered, -> { order(:list_type, :position) } class << self def destroyable_types diff --git a/app/models/namespace.rb b/app/models/namespace.rb index f9b53b2b70a..af50293a179 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -250,7 +250,9 @@ class Namespace < ApplicationRecord end def root_ancestor - self_and_ancestors.reorder(nil).find_by(parent_id: nil) + strong_memoize(:root_ancestor) do + self_and_ancestors.reorder(nil).find_by(parent_id: nil) + end end def subgroup? @@ -291,6 +293,10 @@ class Namespace < ApplicationRecord end end + def aggregation_scheduled? + aggregation_schedule.present? + end + private def parent_changed? diff --git a/app/models/namespace/aggregation_schedule.rb b/app/models/namespace/aggregation_schedule.rb index 43afd0b954c..355593597c6 100644 --- a/app/models/namespace/aggregation_schedule.rb +++ b/app/models/namespace/aggregation_schedule.rb @@ -1,7 +1,47 @@ # frozen_string_literal: true class Namespace::AggregationSchedule < ApplicationRecord + include AfterCommitQueue + include ExclusiveLeaseGuard + self.primary_key = :namespace_id + DEFAULT_LEASE_TIMEOUT = 3.hours + REDIS_SHARED_KEY = 'gitlab:update_namespace_statistics_delay'.freeze + belongs_to :namespace + + after_create :schedule_root_storage_statistics + + def self.delay_timeout + redis_timeout = Gitlab::Redis::SharedState.with do |redis| + redis.get(REDIS_SHARED_KEY) + end + + redis_timeout.nil? ? DEFAULT_LEASE_TIMEOUT : redis_timeout.to_i + end + + def schedule_root_storage_statistics + run_after_commit_or_now do + try_obtain_lease do + Namespaces::RootStatisticsWorker + .perform_async(namespace_id) + + Namespaces::RootStatisticsWorker + .perform_in(self.class.delay_timeout, namespace_id) + end + end + end + + private + + # Used by ExclusiveLeaseGuard + def lease_timeout + self.class.delay_timeout + end + + # Used by ExclusiveLeaseGuard + def lease_key + "namespace:namespaces_root_statistics:#{namespace_id}" + end end diff --git a/app/models/namespace/root_storage_statistics.rb b/app/models/namespace/root_storage_statistics.rb index de28eb6b37f..56c430013ee 100644 --- a/app/models/namespace/root_storage_statistics.rb +++ b/app/models/namespace/root_storage_statistics.rb @@ -1,10 +1,38 @@ # frozen_string_literal: true class Namespace::RootStorageStatistics < ApplicationRecord + STATISTICS_ATTRIBUTES = %w(storage_size repository_size wiki_size lfs_objects_size build_artifacts_size packages_size).freeze + self.primary_key = :namespace_id belongs_to :namespace has_one :route, through: :namespace delegate :all_projects, to: :namespace + + def recalculate! + update!(attributes_from_project_statistics) + end + + private + + def attributes_from_project_statistics + from_project_statistics + .take + .attributes + .slice(*STATISTICS_ATTRIBUTES) + end + + def from_project_statistics + all_projects + .joins('INNER JOIN project_statistics ps ON ps.project_id = projects.id') + .select( + 'COALESCE(SUM(ps.storage_size), 0) AS storage_size', + 'COALESCE(SUM(ps.repository_size), 0) AS repository_size', + 'COALESCE(SUM(ps.wiki_size), 0) AS wiki_size', + 'COALESCE(SUM(ps.lfs_objects_size), 0) AS lfs_objects_size', + 'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size', + 'COALESCE(SUM(ps.packages_size), 0) AS packages_size' + ) + end end diff --git a/app/models/note.rb b/app/models/note.rb index b55af7d9b5e..4e9ea146485 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -452,7 +452,7 @@ class Note < ApplicationRecord noteable_object&.touch - # We return the noteable object so we can re-use it in EE for ElasticSearch. + # We return the noteable object so we can re-use it in EE for Elasticsearch. noteable_object end diff --git a/app/models/postgresql/replication_slot.rb b/app/models/postgresql/replication_slot.rb index 74ccf23cf69..7a123deb719 100644 --- a/app/models/postgresql/replication_slot.rb +++ b/app/models/postgresql/replication_slot.rb @@ -28,7 +28,7 @@ module Postgresql # We force the use of a transaction here so the query always goes to the # primary, even when using the EE DB load balancer. sizes = transaction { pluck(lag_function) } - too_great = sizes.count { |size| size >= max } + too_great = sizes.compact.count { |size| size >= max } # If too many replicas are falling behind too much, the availability of a # GitLab instance might suffer. To prevent this from happening we require diff --git a/app/models/project.rb b/app/models/project.rb index c642b65f674..0f4fba5d0b6 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -306,7 +306,6 @@ class Project < ApplicationRecord delegate :add_guest, :add_reporter, :add_developer, :add_maintainer, :add_role, to: :team delegate :add_master, to: :team # @deprecated delegate :group_runners_enabled, :group_runners_enabled=, :group_runners_enabled?, to: :ci_cd_settings - delegate :group_clusters_enabled?, to: :group, allow_nil: true delegate :root_ancestor, to: :namespace, allow_nil: true delegate :last_pipeline, to: :commit, allow_nil: true delegate :external_dashboard_url, to: :metrics_setting, allow_nil: true, prefix: true @@ -1446,11 +1445,6 @@ class Project < ApplicationRecord end def in_fork_network_of?(other_project) - # TODO: Remove this in a next release when all fork_networks are populated - # This makes sure all MergeRequests remain valid while the projects don't - # have a fork_network yet. - return true if forked_from?(other_project) - return false if fork_network.nil? || other_project.fork_network.nil? fork_network == other_project.fork_network diff --git a/app/models/project_services/bugzilla_service.rb b/app/models/project_services/bugzilla_service.rb index 1a2bb6a171b..8b79b5e9f0c 100644 --- a/app/models/project_services/bugzilla_service.rb +++ b/app/models/project_services/bugzilla_service.rb @@ -3,22 +3,14 @@ class BugzillaService < IssueTrackerService validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? - prop_accessor :title, :description, :project_url, :issues_url, :new_issue_url + prop_accessor :project_url, :issues_url, :new_issue_url - def title - if self.properties && self.properties['title'].present? - self.properties['title'] - else - 'Bugzilla' - end + def default_title + 'Bugzilla' end - def description - if self.properties && self.properties['description'].present? - self.properties['description'] - else - 'Bugzilla issue tracker' - end + def default_description + s_('IssueTracker|Bugzilla issue tracker') end def self.to_param diff --git a/app/models/project_services/custom_issue_tracker_service.rb b/app/models/project_services/custom_issue_tracker_service.rb index b8f8072869c..535fcf6b94e 100644 --- a/app/models/project_services/custom_issue_tracker_service.rb +++ b/app/models/project_services/custom_issue_tracker_service.rb @@ -5,24 +5,12 @@ class CustomIssueTrackerService < IssueTrackerService prop_accessor :title, :description, :project_url, :issues_url, :new_issue_url - def title - if self.properties && self.properties['title'].present? - self.properties['title'] - else - 'Custom Issue Tracker' - end + def default_title + 'Custom Issue Tracker' end - def title=(value) - self.properties['title'] = value if self.properties - end - - def description - if self.properties && self.properties['description'].present? - self.properties['description'] - else - 'Custom issue tracker' - end + def default_description + s_('IssueTracker|Custom issue tracker') end def self.to_param diff --git a/app/models/project_services/gitlab_issue_tracker_service.rb b/app/models/project_services/gitlab_issue_tracker_service.rb index fa9abf58e62..51032932eab 100644 --- a/app/models/project_services/gitlab_issue_tracker_service.rb +++ b/app/models/project_services/gitlab_issue_tracker_service.rb @@ -5,10 +5,18 @@ class GitlabIssueTrackerService < IssueTrackerService validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? - prop_accessor :title, :description, :project_url, :issues_url, :new_issue_url + prop_accessor :project_url, :issues_url, :new_issue_url default_value_for :default, true + def default_title + 'GitLab' + end + + def default_description + s_('IssueTracker|GitLab issue tracker') + end + def self.to_param 'gitlab' end diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb index f54497fc6d8..3a1130ffc15 100644 --- a/app/models/project_services/issue_tracker_service.rb +++ b/app/models/project_services/issue_tracker_service.rb @@ -5,6 +5,8 @@ class IssueTrackerService < Service default_value_for :category, 'issue_tracker' + before_save :handle_properties + # Pattern used to extract links from comments # Override this method on services that uses different patterns # This pattern does not support cross-project references @@ -18,6 +20,37 @@ class IssueTrackerService < Service end end + # this will be removed as part of https://gitlab.com/gitlab-org/gitlab-ce/issues/63084 + def title + if title_attribute = read_attribute(:title) + title_attribute + elsif self.properties && self.properties['title'].present? + self.properties['title'] + else + default_title + end + end + + # this will be removed as part of https://gitlab.com/gitlab-org/gitlab-ce/issues/63084 + def description + if description_attribute = read_attribute(:description) + description_attribute + elsif self.properties && self.properties['description'].present? + self.properties['description'] + else + default_description + end + end + + def handle_properties + properties.slice('title', 'description').each do |key, _| + current_value = self.properties.delete(key) + value = attribute_changed?(key) ? attribute_change(key).last : current_value + + write_attribute(key, value) + end + end + def default? default end diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index 7b4832b84a8..a3b89b2543a 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -14,17 +14,17 @@ class JiraService < IssueTrackerService format: { with: Gitlab::Regex.jira_transition_id_regex, message: s_("JiraService|transition ids can have only numbers which can be split with , or ;") }, allow_blank: true - # JIRA cloud version is deprecating authentication via username and password. - # We should use username/password for JIRA server and email/api_token for JIRA cloud, + # Jira Cloud version is deprecating authentication via username and password. + # We should use username/password for Jira Server and email/api_token for Jira Cloud, # for more information check: https://gitlab.com/gitlab-org/gitlab-ce/issues/49936. - prop_accessor :username, :password, :url, :api_url, :jira_issue_transition_id, :title, :description + prop_accessor :username, :password, :url, :api_url, :jira_issue_transition_id before_update :reset_password alias_method :project_url, :url # When these are false GitLab does not create cross reference - # comments on JIRA except when an issue gets transitioned. + # comments on Jira except when an issue gets transitioned. def self.supported_events %w(commit merge_request) end @@ -37,7 +37,6 @@ class JiraService < IssueTrackerService def initialize_properties super do self.properties = { - title: issues_tracker['title'], url: issues_tracker['url'], api_url: issues_tracker['api_url'] } @@ -69,25 +68,17 @@ class JiraService < IssueTrackerService end def help - "You need to configure JIRA before enabling this service. For more details + "You need to configure Jira before enabling this service. For more details read the - [JIRA service documentation](#{help_page_url('user/project/integrations/jira')})." + [Jira service documentation](#{help_page_url('user/project/integrations/jira')})." end - def title - if self.properties && self.properties['title'].present? - self.properties['title'] - else - 'JIRA' - end + def default_title + 'Jira' end - def description - if self.properties && self.properties['description'].present? - self.properties['description'] - else - s_('JiraService|Jira issue tracker') - end + def default_description + s_('JiraService|Jira issue tracker') end def self.to_param @@ -97,7 +88,7 @@ class JiraService < IssueTrackerService def fields [ { type: 'text', name: 'url', title: s_('JiraService|Web URL'), placeholder: 'https://jira.example.com', required: true }, - { type: 'text', name: 'api_url', title: s_('JiraService|JIRA API URL'), placeholder: s_('JiraService|If different from Web URL') }, + { type: 'text', name: 'api_url', title: s_('JiraService|Jira API URL'), placeholder: s_('JiraService|If different from Web URL') }, { type: 'text', name: 'username', title: s_('JiraService|Username or Email'), placeholder: s_('JiraService|Use a username for server version and an email for cloud version'), required: true }, { type: 'password', name: 'password', title: s_('JiraService|Password or API token'), placeholder: s_('JiraService|Use a password for server version and an API token for cloud version'), required: true }, { type: 'text', name: 'jira_issue_transition_id', title: s_('JiraService|Transition ID(s)'), placeholder: s_('JiraService|Use , or ; to separate multiple transition IDs') } @@ -130,7 +121,7 @@ class JiraService < IssueTrackerService commit_url = build_entity_url(:commit, commit_id) - # Depending on the JIRA project's workflow, a comment during transition + # Depending on the Jira project's workflow, a comment during transition # may or may not be allowed. Refresh the issue after transition and check # if it is closed, so we don't have one comment for every commit. issue = jira_request { client.Issue.find(issue.key) } if transition_issue(issue) @@ -177,7 +168,7 @@ class JiraService < IssueTrackerService { success: success, result: result } end - # JIRA does not need test data. + # Jira does not need test data. # We are requesting the project that belongs to the project key. def test_data(user = nil, project = nil) nil @@ -313,7 +304,7 @@ class JiraService < IssueTrackerService name == "project_snippet" ? "snippet" : name end - # Handle errors when doing JIRA API calls + # Handle errors when doing Jira API calls def jira_request yield @@ -339,9 +330,9 @@ class JiraService < IssueTrackerService def self.event_description(event) case event when "merge_request", "merge_request_events" - s_("JiraService|JIRA comments will be created when an issue gets referenced in a merge request.") + s_("JiraService|Jira comments will be created when an issue gets referenced in a merge request.") when "commit", "commit_events" - s_("JiraService|JIRA comments will be created when an issue gets referenced in a commit.") + s_("JiraService|Jira comments will be created when an issue gets referenced in a commit.") end end end diff --git a/app/models/project_services/redmine_service.rb b/app/models/project_services/redmine_service.rb index a80be4b06da..5ca057ca833 100644 --- a/app/models/project_services/redmine_service.rb +++ b/app/models/project_services/redmine_service.rb @@ -3,22 +3,14 @@ class RedmineService < IssueTrackerService validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? - prop_accessor :title, :description, :project_url, :issues_url, :new_issue_url + prop_accessor :project_url, :issues_url, :new_issue_url - def title - if self.properties && self.properties['title'].present? - self.properties['title'] - else - 'Redmine' - end + def default_title + 'Redmine' end - def description - if self.properties && self.properties['description'].present? - self.properties['description'] - else - 'Redmine issue tracker' - end + def default_description + s_('IssueTracker|Redmine issue tracker') end def self.to_param diff --git a/app/models/project_services/youtrack_service.rb b/app/models/project_services/youtrack_service.rb index 175c2ebf197..f9de1f7dc49 100644 --- a/app/models/project_services/youtrack_service.rb +++ b/app/models/project_services/youtrack_service.rb @@ -3,7 +3,7 @@ class YoutrackService < IssueTrackerService validates :project_url, :issues_url, presence: true, public_url: true, if: :activated? - prop_accessor :description, :project_url, :issues_url + prop_accessor :project_url, :issues_url # {PROJECT-KEY}-{NUMBER} Examples: YT-1, PRJ-1, gl-030 def self.reference_pattern(only_long: false) @@ -14,16 +14,12 @@ class YoutrackService < IssueTrackerService end end - def title + def default_title 'YouTrack' end - def description - if self.properties && self.properties['description'].present? - self.properties['description'] - else - 'YouTrack issue tracker' - end + def default_description + s_('IssueTracker|YouTrack issue tracker') end def self.to_param diff --git a/app/models/release.rb b/app/models/release.rb index 7bbeb3c9976..459a7c29ad0 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -12,12 +12,16 @@ class Release < ApplicationRecord has_many :links, class_name: 'Releases::Link' + default_value_for :released_at, allows_nil: false do + Time.zone.now + end + accepts_nested_attributes_for :links, allow_destroy: true validates :description, :project, :tag, presence: true validates :name, presence: true, on: :create - scope :sorted, -> { order(created_at: :desc) } + scope :sorted, -> { order(released_at: :desc) } delegate :repository, to: :project @@ -44,6 +48,10 @@ class Release < ApplicationRecord end end + def upcoming_release? + released_at.present? && released_at > Time.zone.now + end + private def actual_sha diff --git a/app/models/repository.rb b/app/models/repository.rb index e05d3dd58ac..992ed7485e5 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -282,46 +282,6 @@ class Repository ref_exists?(keep_around_ref_name(sha)) end - def diverging_commit_counts(branch) - return diverging_commit_counts_without_max(branch) if Feature.enabled?('gitaly_count_diverging_commits_no_max') - - ## TODO: deprecate the below code after 12.0 - @root_ref_hash ||= raw_repository.commit(root_ref).id - cache.fetch(:"diverging_commit_counts_#{branch.name}") do - # Rugged seems to throw a `ReferenceError` when given branch_names rather - # than SHA-1 hashes - branch_sha = branch.dereferenced_target.sha - - number_commits_behind, number_commits_ahead = - raw_repository.diverging_commit_count( - @root_ref_hash, - branch_sha, - max_count: MAX_DIVERGING_COUNT) - - if number_commits_behind + number_commits_ahead >= MAX_DIVERGING_COUNT - { distance: MAX_DIVERGING_COUNT } - else - { behind: number_commits_behind, ahead: number_commits_ahead } - end - end - end - - def diverging_commit_counts_without_max(branch) - @root_ref_hash ||= raw_repository.commit(root_ref).id - cache.fetch(:"diverging_commit_counts_without_max_#{branch.name}") do - # Rugged seems to throw a `ReferenceError` when given branch_names rather - # than SHA-1 hashes - branch_sha = branch.dereferenced_target.sha - - number_commits_behind, number_commits_ahead = - raw_repository.diverging_commit_count( - @root_ref_hash, - branch_sha) - - { behind: number_commits_behind, ahead: number_commits_ahead } - end - end - def archive_metadata(ref, storage_path, format = "tar.gz", append_sha:, path: nil) raw_repository.archive_metadata( ref, diff --git a/app/models/service.rb b/app/models/service.rb index 40033003f3b..752467622f2 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -129,7 +129,7 @@ class Service < ApplicationRecord def api_field_names fields.map { |field| field[:name] } - .reject { |field_name| field_name =~ /(password|token|key)/ } + .reject { |field_name| field_name =~ /(password|token|key|title|description)/ } end def global_fields diff --git a/app/models/snippet.rb b/app/models/snippet.rb index f4fdac2558c..00931457344 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -194,6 +194,10 @@ class Snippet < ApplicationRecord 'snippet' end + def to_ability_name + model_name.singular + end + class << self # Searches for snippets with a matching title or file name. # diff --git a/app/models/user.rb b/app/models/user.rb index 38cb4d1a6e8..26be197209a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1460,7 +1460,7 @@ class User < ApplicationRecord end def requires_usage_stats_consent? - !consented_usage_stats? && 7.days.ago > self.created_at && !has_current_license? && User.single_user? + self.admin? && 7.days.ago > self.created_at && !has_current_license? && User.single_user? && !consented_usage_stats? end # Avoid migrations only building user preference object when needed. @@ -1495,7 +1495,14 @@ class User < ApplicationRecord end def consented_usage_stats? - Gitlab::CurrentSettings.usage_stats_set_by_user_id == self.id + # Bypass the cache here because it's possible the admin enabled the + # usage ping, and we don't want to annoy the user again if they + # already set the value. This is a bit of hack, but the alternative + # would be to put in a more complex cache invalidation step. Since + # this call only gets called in the uncommon situation where the + # user is an admin and the only user in the instance, this shouldn't + # cause too much load on the system. + ApplicationSetting.current_without_cache&.usage_stats_set_by_user_id == self.id end # Added according to https://github.com/plataformatec/devise/blob/7df57d5081f9884849ca15e4fde179ef164a575f/README.md#activejob-integration diff --git a/app/policies/award_emoji_policy.rb b/app/policies/award_emoji_policy.rb new file mode 100644 index 00000000000..21e382e24b3 --- /dev/null +++ b/app/policies/award_emoji_policy.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AwardEmojiPolicy < BasePolicy + delegate { @subject.awardable if DeclarativePolicy.has_policy?(@subject.awardable) } + + condition(:can_read_awardable) do + can?(:"read_#{@subject.awardable.to_ability_name}") + end + + rule { can_read_awardable }.enable :read_emoji +end diff --git a/app/policies/clusters/instance_policy.rb b/app/policies/clusters/instance_policy.rb index e1045c85e6d..f72096e8fc6 100644 --- a/app/policies/clusters/instance_policy.rb +++ b/app/policies/clusters/instance_policy.rb @@ -6,9 +6,8 @@ module Clusters condition(:has_clusters, scope: :subject) { clusterable_has_clusters? } condition(:can_have_multiple_clusters) { multiple_clusters_available? } - condition(:instance_clusters_enabled) { Instance.enabled? } - rule { admin & instance_clusters_enabled }.policy do + rule { admin }.policy do enable :read_cluster enable :add_cluster enable :create_cluster diff --git a/app/presenters/award_emoji_presenter.rb b/app/presenters/award_emoji_presenter.rb new file mode 100644 index 00000000000..98713855d35 --- /dev/null +++ b/app/presenters/award_emoji_presenter.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class AwardEmojiPresenter < Gitlab::View::Presenter::Delegated + presents :award_emoji + + def description + as_emoji['description'] + end + + def unicode + as_emoji['unicode'] + end + + def emoji + as_emoji['moji'] + end + + def unicode_version + Gitlab::Emoji.emoji_unicode_version(award_emoji.name) + end + + private + + def as_emoji + @emoji ||= Gitlab::Emoji.emojis[award_emoji.name] || {} + end +end diff --git a/app/presenters/commit_presenter.rb b/app/presenters/commit_presenter.rb index 05adbe1d4f5..fc9853733c1 100644 --- a/app/presenters/commit_presenter.rb +++ b/app/presenters/commit_presenter.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true -class CommitPresenter < Gitlab::View::Presenter::Simple +class CommitPresenter < Gitlab::View::Presenter::Delegated + include GlobalID::Identification + presents :commit def status_for(ref) @@ -10,4 +12,8 @@ class CommitPresenter < Gitlab::View::Presenter::Simple def any_pipelines? can?(current_user, :read_pipeline, commit.project) && commit.pipelines.any? end + + def web_url + Gitlab::UrlBuilder.new(commit).url + end end diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index 43aced598a9..fd2673fa0cc 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -217,8 +217,12 @@ class MergeRequestWidgetEntity < IssuableEntity project_merge_request_path(merge_request.project, merge_request, format: :diff) end - expose :status_path do |merge_request| - project_merge_request_path(merge_request.target_project, merge_request, format: :json) + expose :merge_request_basic_path do |merge_request| + project_merge_request_path(merge_request.target_project, merge_request, serializer: :basic, format: :json) + end + + expose :merge_request_widget_path do |merge_request| + widget_project_json_merge_request_path(merge_request.target_project, merge_request, format: :json) end expose :ci_environments_status_path do |merge_request| diff --git a/app/services/branches/diverging_commit_counts_service.rb b/app/services/branches/diverging_commit_counts_service.rb new file mode 100644 index 00000000000..f947cec1663 --- /dev/null +++ b/app/services/branches/diverging_commit_counts_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Branches + class DivergingCommitCountsService + def initialize(repository) + @repository = repository + @cache = Gitlab::RepositoryCache.new(repository) + end + + def call(branch) + if Feature.enabled?('gitaly_count_diverging_commits_no_max') + diverging_commit_counts_without_max(branch) + else + diverging_commit_counts(branch) + end + end + + private + + attr_reader :repository, :cache + + delegate :raw_repository, to: :repository + + def diverging_commit_counts(branch) + ## TODO: deprecate the below code after 12.0 + @root_ref_hash ||= raw_repository.commit(repository.root_ref).id + cache.fetch(:"diverging_commit_counts_#{branch.name}") do + number_commits_behind, number_commits_ahead = + repository.raw_repository.diverging_commit_count( + @root_ref_hash, + branch.dereferenced_target.sha, + max_count: Repository::MAX_DIVERGING_COUNT) + + if number_commits_behind + number_commits_ahead >= Repository::MAX_DIVERGING_COUNT + { distance: Repository::MAX_DIVERGING_COUNT } + else + { behind: number_commits_behind, ahead: number_commits_ahead } + end + end + end + + def diverging_commit_counts_without_max(branch) + @root_ref_hash ||= raw_repository.commit(repository.root_ref).id + cache.fetch(:"diverging_commit_counts_without_max_#{branch.name}") do + number_commits_behind, number_commits_ahead = + raw_repository.diverging_commit_count( + @root_ref_hash, + branch.dereferenced_target.sha) + + { behind: number_commits_behind, ahead: number_commits_ahead } + end + end + end +end diff --git a/app/services/deploy_tokens/create_service.rb b/app/services/deploy_tokens/create_service.rb index dc0122002e9..327a1dbf408 100644 --- a/app/services/deploy_tokens/create_service.rb +++ b/app/services/deploy_tokens/create_service.rb @@ -3,7 +3,9 @@ module DeployTokens class CreateService < BaseService def execute - @project.deploy_tokens.create(params) + @project.deploy_tokens.create(params) do |deploy_token| + deploy_token.username = params[:username].presence + end end end end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 26132f1824a..02de080e0ba 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -205,7 +205,7 @@ class IssuableBaseService < BaseService end if issuable.changed? || params.present? - issuable.assign_attributes(params.merge(updated_by: current_user)) + issuable.assign_attributes(params) if has_title_or_description_changed?(issuable) issuable.assign_attributes(last_edited_at: Time.now, last_edited_by: current_user) @@ -213,11 +213,16 @@ class IssuableBaseService < BaseService before_update(issuable) + # Do not touch when saving the issuable if only changes position within a list. We should call + # this method at this point to capture all possible changes. + should_touch = update_timestamp?(issuable) + + issuable.updated_by = current_user if should_touch # We have to perform this check before saving the issuable as Rails resets # the changed fields upon calling #save. update_project_counters = issuable.project && update_project_counter_caches?(issuable) - if issuable.with_transaction_returning_status { issuable.save } + if issuable.with_transaction_returning_status { issuable.save(touch: should_touch) } # We do not touch as it will affect a update on updated_at field ActiveRecord::Base.no_touching do Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: old_associations[:labels]) @@ -402,4 +407,8 @@ class IssuableBaseService < BaseService def ensure_milestone_available(issuable) issuable.milestone_id = nil unless issuable.milestone_available? end + + def update_timestamp?(issuable) + issuable.changes.keys != ["relative_position"] + end end diff --git a/app/services/merge_requests/create_from_issue_service.rb b/app/services/merge_requests/create_from_issue_service.rb index e69791872cc..2a217a6f689 100644 --- a/app/services/merge_requests/create_from_issue_service.rb +++ b/app/services/merge_requests/create_from_issue_service.rb @@ -6,17 +6,20 @@ module MergeRequests # branch - the name of new branch # ref - the source of new branch. - @branch_name = params[:branch_name] - @issue_iid = params[:issue_iid] - @ref = params[:ref] + @branch_name = params[:branch_name] + @issue_iid = params[:issue_iid] + @ref = params[:ref] + @target_project_id = params[:target_project_id] super(project, user) end def execute + return error('Project not found') if target_project.blank? + return error('Not allowed to create merge request') unless can_create_merge_request? return error('Invalid issue iid') unless @issue_iid.present? && issue.present? - result = CreateBranchService.new(project, current_user).execute(branch_name, ref) + result = CreateBranchService.new(target_project, current_user).execute(branch_name, ref) return result if result[:status] == :error new_merge_request = create(merge_request) @@ -26,7 +29,7 @@ module MergeRequests success(new_merge_request) else - SystemNoteService.new_issue_branch(issue, project, current_user, branch_name) + SystemNoteService.new_issue_branch(issue, project, current_user, branch_name, branch_project: target_project) error(new_merge_request.errors) end @@ -34,6 +37,10 @@ module MergeRequests private + def can_create_merge_request? + can?(current_user, :create_merge_request_from, target_project) + end + # rubocop: disable CodeReuse/ActiveRecord def issue @issue ||= IssuesFinder.new(current_user, project_id: project.id).find_by(iid: @issue_iid) @@ -45,21 +52,21 @@ module MergeRequests end def ref - return @ref if project.repository.branch_exists?(@ref) + return @ref if target_project.repository.branch_exists?(@ref) - project.default_branch || 'master' + target_project.default_branch || 'master' end def merge_request - MergeRequests::BuildService.new(project, current_user, merge_request_params).execute + MergeRequests::BuildService.new(target_project, current_user, merge_request_params).execute end def merge_request_params { issue_iid: @issue_iid, - source_project_id: project.id, + source_project_id: target_project.id, source_branch: branch_name, - target_project_id: project.id, + target_project_id: target_project.id, target_branch: ref } end @@ -67,5 +74,14 @@ module MergeRequests def success(merge_request) super().merge(merge_request: merge_request) end + + def target_project + @target_project ||= + if @target_project_id.present? + project.forks.find_by_id(@target_project_id) + else + project + end + end end end diff --git a/app/services/namespaces/statistics_refresher_service.rb b/app/services/namespaces/statistics_refresher_service.rb new file mode 100644 index 00000000000..c07b302839b --- /dev/null +++ b/app/services/namespaces/statistics_refresher_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Namespaces + class StatisticsRefresherService + RefresherError = Class.new(StandardError) + + def execute(root_namespace) + root_storage_statistics = find_or_create_root_storage_statistics(root_namespace.id) + + root_storage_statistics.recalculate! + rescue ActiveRecord::ActiveRecordError => e + raise RefresherError.new(e.message) + end + + private + + def find_or_create_root_storage_statistics(root_namespace_id) + Namespace::RootStorageStatistics + .safe_find_or_create_by!(namespace_id: root_namespace_id) + end + end +end diff --git a/app/services/releases/concerns.rb b/app/services/releases/concerns.rb index ff6b696ca96..618d96717b8 100644 --- a/app/services/releases/concerns.rb +++ b/app/services/releases/concerns.rb @@ -22,6 +22,10 @@ module Releases params[:description] end + def released_at + params[:released_at] + end + def release strong_memoize(:release) do project.releases.find_by_tag(tag_name) diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb index a271a7e5e49..5b13ac631ba 100644 --- a/app/services/releases/create_service.rb +++ b/app/services/releases/create_service.rb @@ -58,6 +58,7 @@ module Releases author: current_user, tag: tag.name, sha: tag.dereferenced_target.sha, + released_at: released_at, links_attributes: params.dig(:assets, 'links') || [] ) end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 1390f7cdf46..8f7cfe582ca 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -404,8 +404,9 @@ module SystemNoteService # Example note text: # # "created branch `201-issue-branch-button`" - def new_issue_branch(issue, project, author, branch) - link = url_helpers.project_compare_path(project, from: project.default_branch, to: branch) + def new_issue_branch(issue, project, author, branch, branch_project: nil) + branch_project ||= project + link = url_helpers.project_compare_path(branch_project, from: branch_project.default_branch, to: branch) body = "created branch [`#{branch}`](#{link}) to address this issue" @@ -413,7 +414,7 @@ module SystemNoteService end def new_merge_request(issue, project, author, merge_request) - body = "created merge request #{merge_request.to_reference} to address this issue" + body = "created merge request #{merge_request.to_reference(project)} to address this issue" create_note(NoteSummary.new(issue, project, author, body, action: 'merge')) end diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb index 15c13a452ad..8f52e9cb23f 100644 --- a/app/services/users/update_service.rb +++ b/app/services/users/update_service.rb @@ -63,12 +63,20 @@ module Users def assign_identity return unless identity_params.present? - identity = user.identities.find_or_create_by(provider: identity_params[:provider]) # rubocop: disable CodeReuse/ActiveRecord + identity = user.identities.find_or_create_by(provider_params) # rubocop: disable CodeReuse/ActiveRecord identity.update(identity_params) end def identity_attributes [:provider, :extern_uid] end + + def provider_attributes + [:provider] + end + + def provider_params + identity_params.slice(*provider_attributes) + end end end diff --git a/app/views/admin/services/_form.html.haml b/app/views/admin/services/_form.html.haml index 97373a3c350..ab08d5c4906 100644 --- a/app/views/admin/services/_form.html.haml +++ b/app/views/admin/services/_form.html.haml @@ -1,7 +1,7 @@ %h3.page-title = @service.title -%p #{@service.description} template +%p #{@service.description} template. = form_for :service, url: admin_application_settings_service_path, method: :put, html: { class: 'fieldset-form' } do |form| = render 'shared/service_settings', form: form, subject: @service diff --git a/app/views/clusters/clusters/_form.html.haml b/app/views/clusters/clusters/_form.html.haml index 455322b2089..3d0266a2d5b 100644 --- a/app/views/clusters/clusters/_form.html.haml +++ b/app/views/clusters/clusters/_form.html.haml @@ -1,4 +1,4 @@ -= form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster do |field| += form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster, html: { class: 'cluster_integration_form' } do |field| = form_errors(@cluster) .form-group %h5= s_('ClusterIntegration|Integration status') diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml index e12748666c8..db1849ebb45 100644 --- a/app/views/groups/settings/_general.html.haml +++ b/app/views/groups/settings/_general.html.haml @@ -13,7 +13,7 @@ = f.text_field :id, class: 'form-control w-auto', readonly: true .row.prepend-top-8 - .form-group.col-md-9.append-bottom-0 + .form-group.col-md-9 = f.label :description, _('Group description (optional)'), class: 'label-bold' = f.text_area :description, class: 'form-control', rows: 3, maxlength: 250 diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index e401488ecff..a9af5ba5008 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -347,7 +347,7 @@ = _('Settings') %li.divider.fly-out-top-item = nav_link(path: %w[projects#edit]) do - = link_to edit_project_path(@project), title: _('General') do + = link_to edit_project_path(@project), title: _('General'), class: 'qa-general-settings-link' do %span = _('General') = nav_link(controller: :project_members) do diff --git a/app/views/notify/new_merge_request_email.text.erb b/app/views/notify/new_merge_request_email.text.erb index c3f2902c78a..6c0d7b1e60b 100644 --- a/app/views/notify/new_merge_request_email.text.erb +++ b/app/views/notify/new_merge_request_email.text.erb @@ -1,7 +1,7 @@ <%= @merge_request.author_name %> <%= 'created a merge request:' %> <%= url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) %> <%= merge_path_description(@merge_request, 'to') %> -<%= 'Author' %>: <%= @merge_request.author_name %> +<%= 'Author:' %> <%= @merge_request.author_name %> <%= assignees_label(@merge_request) %> <%= render_if_exists 'notify/merge_request_approvers', presenter: @mr_presenter %> diff --git a/app/views/projects/_commit_button.html.haml b/app/views/projects/_commit_button.html.haml index 1e27c71d20d..b6689f4b57a 100644 --- a/app/views/projects/_commit_button.html.haml +++ b/app/views/projects/_commit_button.html.haml @@ -1,5 +1,5 @@ .form-actions - = button_tag 'Commit changes', class: 'btn commit-btn js-commit-button btn-success' + = button_tag 'Commit changes', class: 'btn commit-btn js-commit-button btn-success qa-commit-button' = link_to 'Cancel', cancel_path, class: 'btn btn-cancel', data: {confirm: leave_edit_message} diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 2b0c3985755..6763513f9ae 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -9,7 +9,9 @@ .nav-block = render 'projects/tree/tree_header', tree: @tree - - if commit + - if vue_file_list_enabled? + #js-last-commit + - elsif commit = render 'shared/commit_well', commit: commit, ref: ref, project: project - if is_project_overview diff --git a/app/views/projects/_merge_request_settings_description_text.html.haml b/app/views/projects/_merge_request_settings_description_text.html.haml new file mode 100644 index 00000000000..42964c900b3 --- /dev/null +++ b/app/views/projects/_merge_request_settings_description_text.html.haml @@ -0,0 +1 @@ +%p= s_('ProjectSettings|Choose your merge method, merge options, and merge checks.') diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index a54460f1196..283b845e40d 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -32,7 +32,7 @@ = select_tag :encoding, options_for_select([ "base64", "text" ], "text"), class: 'select2', tabindex: '-1' .file-editor.code - %pre.js-edit-mode-pane#editor= params[:content] || local_assigns[:blob_data] + %pre.js-edit-mode-pane.qa-editor#editor= params[:content] || local_assigns[:blob_data] - if local_assigns[:path] .js-edit-mode-pane#preview.hide .center diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index 3638334d61c..dbff2115f50 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -1,11 +1,7 @@ - merged = local_assigns.fetch(:merged, false) - commit = @repository.commit(branch.dereferenced_target) -- diverging_commit_counts = @repository.diverging_commit_counts(branch) -- number_commits_distance = diverging_commit_counts[:distance] -- number_commits_behind = diverging_commit_counts[:behind] -- number_commits_ahead = diverging_commit_counts[:ahead] - merge_project = merge_request_source_project_for_project(@project) -%li{ class: "branch-item js-branch-#{branch.name}" } +%li{ class: "branch-item js-branch-item js-branch-#{branch.name}", data: { name: branch.name } } .branch-info .branch-title = sprite_icon('fork', size: 12) @@ -30,7 +26,7 @@ = s_('Branches|Cant find HEAD commit for this branch') - if branch.name != @repository.root_ref - .js-branch-divergence-graph{ data: { distance: number_commits_distance, ahead_count: number_commits_ahead, behind_count: number_commits_behind, default_branch: @repository.root_ref, max_commits: @max_commits } } + .js-branch-divergence-graph .controls.d-none.d-md-block< - if merge_project && create_mr_button?(@repository.root_ref, branch.name) diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml index d270e461ac8..11340d12423 100644 --- a/app/views/projects/branches/index.html.haml +++ b/app/views/projects/branches/index.html.haml @@ -47,6 +47,7 @@ = render_if_exists 'projects/commits/mirror_status' + .js-branch-list{ data: { diverging_counts_endpoint: diverging_commit_counts_namespace_project_branches_path(@project.namespace, @project, format: :json) } } - if can?(current_user, :admin_project, @project) - project_settings_link = link_to s_('Branches|project settings'), project_protected_branches_path(@project) .row-content-block diff --git a/app/views/projects/deploy_tokens/_form.html.haml b/app/views/projects/deploy_tokens/_form.html.haml index 5412fcbc9d8..f846dbd3763 100644 --- a/app/views/projects/deploy_tokens/_form.html.haml +++ b/app/views/projects/deploy_tokens/_form.html.haml @@ -13,6 +13,11 @@ = f.text_field :expires_at, class: 'datepicker form-control qa-deploy-token-expires-at', value: f.object.expires_at .form-group + = f.label :username, class: 'label-bold' + = f.text_field :username, class: 'form-control qa-deploy-token-username' + .text-secondary= s_('DeployTokens|Default format is "gitlab+deploy-token-{n}". Enter custom username if you want to change it.') + + .form-group = f.label :scopes, class: 'label-bold' %fieldset.form-group.form-check = f.check_box :read_repository, class: 'form-check-input qa-deploy-token-read-repository' diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index c15b84d0aac..3403564992e 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -27,7 +27,7 @@ .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Merge requests') %button.btn.btn-default.js-settings-toggle{ type: 'button' }= expanded ? _('Collapse') : _('Expand') - %p= _('Choose your merge method, options, checks, and set up a default merge request description template.') + = render_if_exists 'projects/merge_request_settings_description_text' .settings-content = render_if_exists 'shared/promotions/promote_mr_features' @@ -121,7 +121,7 @@ %li= _('You can only transfer the project to namespaces you manage.') %li= _('You will need to update your local repositories to point to the new location.') %li= _('Project visibility level will be changed to match namespace rules when transferring to a group.') - = f.submit 'Transfer project', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => transfer_project_message(@project) } + = f.submit 'Transfer project', class: "btn btn-remove js-confirm-danger qa-transfer-button", data: { "confirm-danger-message" => transfer_project_message(@project) } - if @project.forked? && can?(current_user, :remove_fork_project, @project) .sub-section diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index d59b2d4fb01..c13a47b0b09 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -31,21 +31,19 @@ = button_to stop_project_environment_path(@project, @environment), class: 'btn btn-danger has-tooltip', method: :post do = s_('Environments|Stop environment') - .row.top-area.adjust - .col-md-7 - %h3.page-title= @environment.name - .col-md-5 - .nav-controls - = render 'projects/environments/terminal_button', environment: @environment - = render 'projects/environments/external_url', environment: @environment - = render 'projects/environments/metrics_button', environment: @environment - - if can?(current_user, :update_environment, @environment) - = link_to _('Edit'), edit_project_environment_path(@project, @environment), class: 'btn' - - if can?(current_user, :stop_environment, @environment) - = button_tag class: 'btn btn-danger', type: 'button', data: { toggle: 'modal', - target: '#stop-environment-modal' } do - = sprite_icon('stop') - = s_('Environments|Stop') + .top-area + %h3.page-title= @environment.name + .nav-controls.ml-auto.my-2 + = render 'projects/environments/terminal_button', environment: @environment + = render 'projects/environments/external_url', environment: @environment + = render 'projects/environments/metrics_button', environment: @environment + - if can?(current_user, :update_environment, @environment) + = link_to _('Edit'), edit_project_environment_path(@project, @environment), class: 'btn' + - if can?(current_user, :stop_environment, @environment) + = button_tag class: 'btn btn-danger', type: 'button', data: { toggle: 'modal', + target: '#stop-environment-modal' } do + = sprite_icon('stop') + = s_('Environments|Stop') .environments-container - if @deployments.blank? diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index db1f15f96b8..e34973f1f43 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -1,49 +1,9 @@ -- page_title "Container Registry" - %section - .settings-header - %h4 - = page_title - %p - = s_('ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images.') - %p.append-bottom-0 - = succeed '.' do - = s_('ContainerRegistry|Learn more about') - = link_to _('Container Registry'), help_page_path('user/project/container_registry'), target: '_blank' .row.registry-placeholder.prepend-bottom-10 - .col-lg-12 - #js-vue-registry-images{ data: { endpoint: project_container_registry_index_path(@project, format: :json) } } - - .row.prepend-top-10 - .col-lg-12 - .card - .card-header - = s_('ContainerRegistry|How to use the Container Registry') - .card-body - %p - - link_token = link_to(_('personal access token'), help_page_path('user/profile/account/two_factor_authentication', anchor: 'personal-access-tokens'), target: '_blank') - - link_2fa = link_to(_('2FA enabled'), help_page_path('user/profile/account/two_factor_authentication'), target: '_blank') - = s_('ContainerRegistry|First log in to GitLab’s Container Registry using your GitLab username and password. If you have %{link_2fa} you need to use a %{link_token}:').html_safe % { link_2fa: link_2fa, link_token: link_token } - %pre - docker login #{Gitlab.config.registry.host_port} - %br - %p - - deploy_token = link_to(_('deploy token'), help_page_path('user/project/deploy_tokens/index', anchor: 'read-container-registry-images'), target: '_blank') - = s_('ContainerRegistry|You can also use a %{deploy_token} for read-only access to the registry images.').html_safe % { deploy_token: deploy_token } - %br - %p - = s_('ContainerRegistry|Once you log in, you’re free to create and upload a container image using the common %{build} and %{push} commands').html_safe % { build: "<code>build</code>".html_safe, push: "<code>push</code>".html_safe } - %pre - :plain - docker build -t #{escape_once(@project.container_registry_url)} . - docker push #{escape_once(@project.container_registry_url)} - %hr - %h5.prepend-top-default - = s_('ContainerRegistry|Use different image names') - %p.light - = s_('ContainerRegistry|GitLab supports up to 3 levels of image names. The following examples of images are valid for your project:') - %pre - :plain - #{escape_once(@project.container_registry_url)}:tag - #{escape_once(@project.container_registry_url)}/optional-image-name:tag - #{escape_once(@project.container_registry_url)}/optional-name/optional-image-name:tag + .col-12 + #js-vue-registry-images{ data: { endpoint: project_container_registry_index_path(@project, format: :json), + "help_page_path" => help_page_path('user/project/container_registry'), + "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), + "containers_error_image" => image_path('illustrations/docker-error-state.svg'), + "repository_url" => escape_once(@project.container_registry_url), + character_error: @character_error.to_s } } diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index ea6349f2f57..1d0bc588c9c 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -76,6 +76,7 @@ #{ _('New tag') } .tree-controls + = render_if_exists 'projects/tree/lock_link' = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' = render 'projects/find_file_link' diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml index df408e5fb60..ee7d89a9bd8 100644 --- a/app/views/search/_category.html.haml +++ b/app/views/search/_category.html.haml @@ -87,4 +87,5 @@ = _("Milestones") %span.badge.badge-pill = limited_count(@search_results.limited_milestones_count) + = render_if_exists 'search/category_elasticsearch' = users diff --git a/app/views/shared/_confirm_modal.html.haml b/app/views/shared/_confirm_modal.html.haml index 3967c8148d2..8e3b482e27d 100644 --- a/app/views/shared/_confirm_modal.html.haml +++ b/app/views/shared/_confirm_modal.html.haml @@ -1,4 +1,4 @@ -#modal-confirm-danger.modal{ tabindex: -1 } +#modal-confirm-danger.modal.qa-confirm-modal{ tabindex: -1 } .modal-dialog .modal-content .modal-header @@ -17,6 +17,6 @@ to proceed or close this modal to cancel. .form-group - = text_field_tag 'confirm_name_input', '', class: 'form-control js-confirm-danger-input' + = text_field_tag 'confirm_name_input', '', class: 'form-control js-confirm-danger-input qa-confirm-input' .form-actions - = submit_tag _('Confirm'), class: "btn btn-danger js-confirm-danger-submit" + = submit_tag _('Confirm'), class: "btn btn-danger js-confirm-danger-submit qa-confirm-button" diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml index f9cfcabc015..6c0613605eb 100644 --- a/app/views/shared/boards/components/_board.html.haml +++ b/app/views/shared/boards/components/_board.html.haml @@ -1,52 +1,62 @@ .board.d-inline-block.h-100.px-2.align-top.ws-normal{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded, "board-type-assignee": list.type === "assignee" }', ":data-id" => "list.id" } .board-inner.d-flex.flex-column.position-relative.h-100.rounded - %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color, "position-relative": list.isExpanded, "position-absolute position-top-0 position-left-0 w-100 h-100": !list.isExpanded }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }", "@click" => "toggleExpanded($event)" } - %h3.board-title.m-0.d-flex.align-items-center.py-2.px-3.js-board-handle{ ":class" => '{ "user-can-drag": (!disabled && !list.preset), "p-0 border-bottom-0 justify-content-center": !list.isExpanded }' } - %i.fa.fa-fw.board-title-expandable-toggle{ "v-if": "list.isExpandable", - ":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded }", - "aria-hidden": "true" } + %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color, "position-relative": list.isExpanded, "position-absolute position-top-0 position-left-0 w-100 h-100": !list.isExpanded }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }" } + %h3.board-title.m-0.d-flex.js-board-handle{ ":class" => '{ "user-can-drag": (!disabled && !list.preset), "border-bottom-0": !list.isExpanded }' } + + .board-title-caret.no-drag{ "v-if": "list.isExpandable", + "aria-hidden": "true", + ":aria-label": "caretTooltip", + ":title": "caretTooltip", + "v-tooltip": "", + data: { placement: "bottom" }, + "@click": "toggleExpanded" } + %i.fa.fa-fw{ ":class": '{ "fa-caret-right": list.isExpanded, "fa-caret-down": !list.isExpanded }' } = render_if_exists "shared/boards/components/list_milestone" %a.user-avatar-link.js-no-trigger{ "v-if": "list.type === \"assignee\"", ":href": "list.assignee.path" } -# haml-lint:disable AltText %img.avatar.s20.has-tooltip{ height: "20", width: "20", ":src": "list.assignee.avatar", ":alt": "list.assignee.name" } - %span.board-title-text.has-tooltip.block-truncated{ "v-if": "list.type !== \"label\"", - ":title" => '((list.label && list.label.description) || list.title || "")', data: { container: "body" } } - {{ list.title }} + .board-title-text + %span.board-title-main-text.block-truncated{ "v-if": "list.type !== \"label\"", + ":title" => '((list.label && list.label.description) || list.title || "")', + data: { container: "body" }, + ":class": "{ 'has-tooltip': !['backlog', 'closed'].includes(list.type) }" } + {{ list.title }} - %span.board-title-sub-text.prepend-left-5.has-tooltip{ "v-if": "list.type === \"assignee\"", - ":title" => '(list.assignee && list.assignee.username || "")' } - @{{ list.assignee.username }} + %span.board-title-sub-text.prepend-left-5.has-tooltip{ "v-if": "list.type === \"assignee\"", + ":title" => '(list.assignee && list.assignee.username || "")' } + @{{ list.assignee.username }} - %span.has-tooltip{ "v-if": "list.type === \"label\"", - ":title" => '(list.label ? list.label.description : "")', - data: { container: "body", placement: "bottom" }, - class: "badge color-label title board-title-text", - ":style" => "{ backgroundColor: (list.label && list.label.color ? list.label.color : null), color: (list.label && list.label.textColor ? list.label.textColor : \"#2e2e2e\") }" } - {{ list.title }} + %span.has-tooltip.badge.color-label.title{ "v-if": "list.type === \"label\"", + ":title" => '(list.label ? list.label.description : "")', + data: { container: "body", placement: "bottom" }, + ":style" => "{ backgroundColor: (list.label && list.label.color ? list.label.color : null), color: (list.label && list.label.textColor ? list.label.textColor : \"#2e2e2e\") }" } + {{ list.title }} - if can?(current_user, :admin_list, current_board_parent) %board-delete{ "inline-template" => true, ":list" => "list", "v-if" => "!list.preset && list.id" } - %button.board-delete.p-0.border-0.has-tooltip.float-right{ type: "button", title: _("Delete list"), ":class": "{ 'd-none': !list.isExpanded }", "aria-label" => _("Delete list"), data: { placement: "bottom" }, "@click.stop" => "deleteBoard" } + %button.board-delete.no-drag.p-0.border-0.has-tooltip.float-right{ type: "button", title: _("Delete list"), ":class": "{ 'd-none': !list.isExpanded }", "aria-label" => _("Delete list"), data: { placement: "bottom" }, "@click.stop" => "deleteBoard" } = icon("trash") - .issue-count-badge.text-secondary{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"', ":title": "counterTooltip", ":class": "{ 'd-none': !list.isExpanded }", "v-tooltip": true, data: { placement: "top" } } - %span.issue-count-badge-count - %icon.mr-1{ name: "issues" } - {{ list.issuesSize }} - = render_if_exists "shared/boards/components/list_weight" - %button.issue-count-badge-add-button.btn.btn-sm.btn-default.ml-1.has-tooltip.js-no-trigger-collapse{ type: "button", + .issue-count-badge.no-drag.text-secondary{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"', ":title": "counterTooltip", "v-tooltip": true, data: { placement: "top" } } + %span.d-inline-flex + %span.issue-count-badge-count + %icon.mr-1{ name: "issues" } + {{ list.issuesSize }} + = render_if_exists "shared/boards/components/list_weight" + + %button.issue-count-badge-add-button.no-drag.btn.btn-sm.btn-default.ml-1.has-tooltip{ type: "button", "@click" => "showNewIssueForm", "v-if" => "isNewIssueShown", ":class": "{ 'd-none': !list.isExpanded }", "aria-label" => _("New issue"), "title" => _("New issue"), data: { placement: "top", container: "body" } } - = icon("plus", class: "js-no-trigger-collapse") + = icon("plus") %board-list{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"', ":list" => "list", diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml index 13847cd9be1..576ec3e1782 100644 --- a/app/views/shared/projects/_list.html.haml +++ b/app/views/shared/projects/_list.html.haml @@ -28,7 +28,7 @@ .js-projects-list-holder - if any_projects?(projects) - - load_pipeline_status(projects) + - load_pipeline_status(projects) if pipeline_status %ul.projects-list{ class: css_classes } - projects.each_with_index do |project, i| - css_class = (i >= projects_limit) || project.pending_delete? ? 'hide' : nil diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index e55962b629e..3d34bfc05c7 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -26,6 +26,7 @@ - cronjob:issue_due_scheduler - cronjob:prune_web_hook_logs - cronjob:schedule_migrate_external_diffs +- cronjob:namespaces_prune_aggregation_schedules - gcp_cluster:cluster_install_app - gcp_cluster:cluster_patch_app @@ -101,6 +102,9 @@ - todos_destroyer:todos_destroyer_project_private - todos_destroyer:todos_destroyer_private_features +- update_namespace_statistics:namespaces_schedule_aggregation +- update_namespace_statistics:namespaces_root_statistics + - object_pool:object_pool_create - object_pool:object_pool_schedule_join - object_pool:object_pool_join diff --git a/app/workers/namespaces/prune_aggregation_schedules_worker.rb b/app/workers/namespaces/prune_aggregation_schedules_worker.rb new file mode 100644 index 00000000000..4e40feee702 --- /dev/null +++ b/app/workers/namespaces/prune_aggregation_schedules_worker.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Namespaces + class PruneAggregationSchedulesWorker + include ApplicationWorker + include CronjobQueue + + # Worker to prune pending rows on Namespace::AggregationSchedule + # It's scheduled to run once a day at 1:05am. + def perform + aggregation_schedules.find_each do |aggregation_schedule| + aggregation_schedule.schedule_root_storage_statistics + end + end + + private + + def aggregation_schedules + Namespace::AggregationSchedule.all + end + end +end diff --git a/app/workers/namespaces/root_statistics_worker.rb b/app/workers/namespaces/root_statistics_worker.rb new file mode 100644 index 00000000000..48876825564 --- /dev/null +++ b/app/workers/namespaces/root_statistics_worker.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Namespaces + class RootStatisticsWorker + include ApplicationWorker + + queue_namespace :update_namespace_statistics + + def perform(namespace_id) + namespace = Namespace.find(namespace_id) + + return unless update_statistics_enabled_for?(namespace) && namespace.aggregation_scheduled? + + Namespaces::StatisticsRefresherService.new.execute(namespace) + + namespace.aggregation_schedule.destroy + rescue ::Namespaces::StatisticsRefresherService::RefresherError, ActiveRecord::RecordNotFound => ex + log_error(namespace.full_path, ex.message) if namespace + end + + private + + def log_error(namespace_path, error_message) + Gitlab::SidekiqLogger.error("Namespace statistics can't be updated for #{namespace_path}: #{error_message}") + end + + def update_statistics_enabled_for?(namespace) + Feature.enabled?(:update_statistics_namespace, namespace) + end + end +end diff --git a/app/workers/namespaces/schedule_aggregation_worker.rb b/app/workers/namespaces/schedule_aggregation_worker.rb new file mode 100644 index 00000000000..a4594b84b13 --- /dev/null +++ b/app/workers/namespaces/schedule_aggregation_worker.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Namespaces + class ScheduleAggregationWorker + include ApplicationWorker + + queue_namespace :update_namespace_statistics + + def perform(namespace_id) + return unless aggregation_schedules_table_exists? + + namespace = Namespace.find(namespace_id) + root_ancestor = namespace.root_ancestor + + return unless update_statistics_enabled_for?(root_ancestor) && !root_ancestor.aggregation_scheduled? + + Namespace::AggregationSchedule.safe_find_or_create_by!(namespace_id: root_ancestor.id) + rescue ActiveRecord::RecordNotFound + log_error(namespace_id) + end + + private + + # On db/post_migrate/20180529152628_schedule_to_archive_legacy_traces.rb + # traces are archived through build.trace.archive, which in consequence + # calls UpdateProjectStatistics#schedule_namespace_statistics_worker. + # + # The migration and specs fails since NamespaceAggregationSchedule table + # does not exist at that point. + # https://gitlab.com/gitlab-org/gitlab-ce/issues/50712 + def aggregation_schedules_table_exists? + return true unless Rails.env.test? + + Namespace::AggregationSchedule.table_exists? + end + + def log_error(root_ancestor_id) + Gitlab::SidekiqLogger.error("Namespace can't be scheduled for aggregation: #{root_ancestor_id} does not exist") + end + + def update_statistics_enabled_for?(root_ancestor) + Feature.enabled?(:update_statistics_namespace, root_ancestor) + end + end +end |