diff options
Diffstat (limited to 'app')
374 files changed, 3785 insertions, 1582 deletions
diff --git a/app/assets/images/auth_buttons/salesforce_64.png b/app/assets/images/auth_buttons/salesforce_64.png Binary files differnew file mode 100644 index 00000000000..c8a86a0c515 --- /dev/null +++ b/app/assets/images/auth_buttons/salesforce_64.png diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js index d0b7f3ff7a2..b23de36f860 100644 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -59,6 +59,14 @@ export default function renderMermaid($els) { mermaid.init(undefined, el, id => { const svg = document.getElementById(id); + // As of https://github.com/knsv/mermaid/commit/57b780a0d, + // Mermaid will make two init callbacks:one to initialize the + // flow charts, and another to initialize the Gannt charts. + // Guard against an error caused by double initialization. + if (svg.classList.contains('mermaid')) { + return; + } + svg.classList.add('mermaid'); // pre > code > svg 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/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index c587b276fa3..2ace0060c42 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -38,6 +38,7 @@ export default Vue.extend({ issue: {}, list: {}, loadingAssignees: false, + timeTrackingLimitToHours: boardsStore.timeTracking.limitToHours, }; }, computed: { 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/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue index 98c1d29db16..3385aad5b11 100644 --- a/app/assets/javascripts/boards/components/issue_time_estimate.vue +++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue @@ -2,6 +2,7 @@ import { GlTooltip } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility'; +import boardsStore from '../stores/boards_store'; export default { components: { @@ -14,12 +15,17 @@ export default { required: true, }, }, + data() { + return { + limitToHours: boardsStore.timeTracking.limitToHours, + }; + }, computed: { title() { - return stringifyTime(parseSeconds(this.estimate), true); + return stringifyTime(parseSeconds(this.estimate, { limitToHours: this.limitToHours }), true); }, timeEstimate() { - return stringifyTime(parseSeconds(this.estimate)); + return stringifyTime(parseSeconds(this.estimate, { limitToHours: this.limitToHours })); }, }, }; 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/index.js b/app/assets/javascripts/boards/index.js index f2f37d22b97..a020765f335 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -49,6 +49,7 @@ export default () => { } boardsStore.create(); + boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours); issueBoardsApp = new Vue({ el: $boardApp, diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js index 983b28d2e67..68ea28e68d9 100644 --- a/app/assets/javascripts/boards/mixins/sortable_default_options.js +++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js @@ -1,7 +1,7 @@ /* global DocumentTouch */ import $ from 'jquery'; -import sortableConfig from '../../sortable/sortable_config'; +import sortableConfig from 'ee_else_ce/sortable/sortable_config'; export function sortableStart() { $('.has-tooltip') @@ -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/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 4b3b44574a8..4ba4cde6bae 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -12,6 +12,9 @@ import eventHub from '../eventhub'; const boardsStore = { disabled: false, + timeTracking: { + limitToHours: false, + }, scopedLabels: { helpLink: '', enabled: false, @@ -222,6 +225,10 @@ const boardsStore = { setIssueDetail(issueDetail) { this.detail.issue = issueDetail; }, + + setTimeTrackingLimitToHours(limitToHours) { + this.timeTracking.limitToHours = parseBoolean(limitToHours); + }, }; BoardsStoreEE.initEESpecific(boardsStore); 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/clusters/services/application_state_machine.js b/app/assets/javascripts/clusters/services/application_state_machine.js index 17ea4d77795..6e632519d8a 100644 --- a/app/assets/javascripts/clusters/services/application_state_machine.js +++ b/app/assets/javascripts/clusters/services/application_state_machine.js @@ -80,6 +80,9 @@ const applicationStateMachine = { installFailed: false, }, }, + [NOT_INSTALLABLE]: { + target: NOT_INSTALLABLE, + }, // This is possible in artificial environments for E2E testing [INSTALLED]: { target: INSTALLED, @@ -108,6 +111,9 @@ const applicationStateMachine = { updateSuccessful: false, }, }, + [NOT_INSTALLABLE]: { + target: NOT_INSTALLABLE, + }, [UNINSTALL_EVENT]: { target: UNINSTALLING, effects: { diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js index d0cc4897aeb..a4394ab7e92 100644 --- a/app/assets/javascripts/commons/polyfills.js +++ b/app/assets/javascripts/commons/polyfills.js @@ -12,6 +12,7 @@ import 'core-js/es/promise/finally'; import 'core-js/es/string/code-point-at'; import 'core-js/es/string/from-code-point'; import 'core-js/es/string/includes'; +import 'core-js/es/string/starts-with'; import 'core-js/es/symbol'; import 'core-js/es/map'; import 'core-js/es/weak-map'; diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js index f66e07ba31a..7817b41514d 100644 --- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js +++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js @@ -32,15 +32,15 @@ const CommentAndResolveBtn = Vue.extend({ buttonText: function() { if (this.isDiscussionResolved) { if (this.textareaIsEmpty) { - return __('Unresolve discussion'); + return __('Unresolve thread'); } else { - return __('Comment & unresolve discussion'); + return __('Comment & unresolve thread'); } } else { if (this.textareaIsEmpty) { - return __('Resolve discussion'); + return __('Resolve thread'); } else { - return __('Comment & resolve discussion'); + return __('Comment & resolve thread'); } } }, diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index aaa9f8b759a..58d5b658b17 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -49,6 +49,8 @@ export default { return this.author.id ? this.author.id : ''; }, authorUrl() { + // TODO: when the vue i18n rules are merged need to disable @gitlab/i18n/no-non-i18n-strings + // name: 'mailto:' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives return this.author.web_url || `mailto:${this.commit.author_email}`; }, authorAvatar() { @@ -80,7 +82,7 @@ export default { v-html="commit.title_html" ></a> - <span class="commit-row-message d-block d-sm-none"> · {{ commit.short_id }} </span> + <span class="commit-row-message d-block d-sm-none">· {{ commit.short_id }}</span> <button v-if="commit.description_html" diff --git a/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue b/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue index 80aec84f574..1dcdb65d5c7 100644 --- a/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue +++ b/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue @@ -1,6 +1,6 @@ <script> import Icon from '~/vue_shared/components/icon.vue'; -import { n__, __ } from '~/locale'; +import { n__, __, sprintf } from '~/locale'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; export default { @@ -54,11 +54,7 @@ export default { }, methods: { commitsText(version) { - return n__( - `${version.commits_count} commit,`, - `${version.commits_count} commits,`, - version.commits_count, - ); + return n__(`%d commit,`, `%d commits,`, version.commits_count); }, href(version) { if (this.isBase(version)) { @@ -76,7 +72,7 @@ export default { if (this.targetBranch && (this.isBase(version) || !version)) { return this.targetBranch.branchName; } - return `version ${version.version_index}`; + return sprintf(__(`version %{versionIndex}`), { versionIndex: version.version_index }); }, isActive(version) { if (!version) { @@ -125,9 +121,9 @@ export default { <div> <strong> {{ versionName(version) }} - <template v-if="isBase(version)"> - (base) - </template> + <template v-if="isBase(version)">{{ + s__('DiffsCompareBaseBranch|(base)') + }}</template> </strong> </div> <div> 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.vue b/app/assets/javascripts/diffs/components/diff_file.vue index f5876a73eff..63350fafefa 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -151,21 +151,22 @@ export default { <div v-if="forkMessageVisible" class="js-file-fork-suggestion-section file-fork-suggestion"> <span class="file-fork-suggestion-note"> - You're not allowed to <span class="js-file-fork-suggestion-section-action">edit</span> files - in this project directly. Please fork this project, make your changes there, and submit a - merge request. + {{ sprintf(__("You're not allowed to %{tag_start}edit%{tag_end} files in this project + directly. Please fork this project, make your changes there, and submit a merge request."), + { tag_start: '<span class="js-file-fork-suggestion-section-action">', tag_end: '</span>' }) + }} </span> <a :href="file.fork_path" class="js-fork-suggestion-button btn btn-grouped btn-inverted btn-success" - >Fork</a + >{{ __('Fork') }}</a > <button class="js-cancel-fork-suggestion-button btn btn-grouped" type="button" @click="hideForkMessage" > - Cancel + {{ __('Cancel') }} </button> </div> <gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" /> 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 7cf3d90d468..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> @@ -74,9 +63,9 @@ export default { <button v-if="discussionsExpanded" type="button" - aria-label="Show comments" + :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..813045cb5e4 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'; @@ -14,7 +15,6 @@ import MonitoringButtonComponent from './environment_monitoring.vue'; import CommitComponent from '../../vue_shared/components/commit.vue'; import eventHub from '../event_hub'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { CLUSTER_TYPE } from '~/clusters/constants'; /** * Environment Item Component @@ -80,15 +80,6 @@ export default { }, /** - * Hide group cluster features which are not currently implemented. - * - * @returns {Boolean} - */ - disableGroupClusterFeatures() { - return this.model && this.model.cluster_type === CLUSTER_TYPE.GROUP; - }, - - /** * Returns whether the environment can be stopped. * * @returns {Boolean} @@ -172,7 +163,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 +286,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?']; }, @@ -575,7 +571,6 @@ export default { <terminal-button-component v-if="model && model.terminal_path" :terminal-path="model.terminal_path" - :disabled="disableGroupClusterFeatures" /> <rollback-component 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/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js index 891086b4142..f280f3cd26c 100644 --- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js +++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js @@ -10,7 +10,7 @@ import { mergeUrlParams } from '../lib/utils/url_utility'; export default class AvailableDropdownMappings { constructor( container, - baseEndpoint, + runnerTagsEndpoint, labelsEndpoint, milestonesEndpoint, groupsOnly, @@ -18,7 +18,7 @@ export default class AvailableDropdownMappings { includeDescendantGroups, ) { this.container = container; - this.baseEndpoint = baseEndpoint; + this.runnerTagsEndpoint = runnerTagsEndpoint; this.labelsEndpoint = labelsEndpoint; this.milestonesEndpoint = milestonesEndpoint; this.groupsOnly = groupsOnly; @@ -149,7 +149,7 @@ export default class AvailableDropdownMappings { } getRunnerTagsEndpoint() { - return `${this.baseEndpoint}/admin/runners/tag_list.json`; + return `${this.runnerTagsEndpoint}.json`; } getMergeRequestTargetBranchesEndpoint() { 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/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index 1cbfd7f9bb9..835d3bf8a53 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -8,7 +8,7 @@ import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; export default class FilteredSearchDropdownManager { constructor({ - baseEndpoint = '', + runnerTagsEndpoint = '', labelsEndpoint = '', milestonesEndpoint = '', tokenizer, @@ -19,7 +19,7 @@ export default class FilteredSearchDropdownManager { filteredSearchTokenKeys, }) { this.container = FilteredSearchContainer.container; - this.baseEndpoint = baseEndpoint.replace(/\/$/, ''); + this.runnerTagsEndpoint = runnerTagsEndpoint.replace(/\/$/, ''); this.labelsEndpoint = labelsEndpoint.replace(/\/$/, ''); this.milestonesEndpoint = milestonesEndpoint.replace(/\/$/, ''); this.tokenizer = tokenizer; @@ -51,7 +51,7 @@ export default class FilteredSearchDropdownManager { const supportedTokens = this.filteredSearchTokenKeys.getKeys(); const availableMappings = new AvailableDropdownMappings( this.container, - this.baseEndpoint, + this.runnerTagsEndpoint, this.labelsEndpoint, this.milestonesEndpoint, this.groupsOnly, diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 450e0725f2e..d1f52b91d9e 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -85,7 +85,8 @@ export default class FilteredSearchManager { if (this.filteredSearchInput) { this.tokenizer = FilteredSearchTokenizer; this.dropdownManager = new FilteredSearchDropdownManager({ - baseEndpoint: this.filteredSearchInput.getAttribute('data-base-endpoint') || '', + runnerTagsEndpoint: + this.filteredSearchInput.getAttribute('data-runner-tags-endpoint') || '', labelsEndpoint: this.filteredSearchInput.getAttribute('data-labels-endpoint') || '', milestonesEndpoint: this.filteredSearchInput.getAttribute('data-milestones-endpoint') || '', tokenizer: this.tokenizer, diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 3b73dd83c9f..b308cd9c236 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -318,6 +318,7 @@ class GfmAutoComplete { } setupLabels($input) { + const instance = this; const fetchData = this.fetchData.bind(this); const LABEL_COMMAND = { LABEL: '/label', UNLABEL: '/unlabel', RELABEL: '/relabel' }; let command = ''; @@ -348,7 +349,6 @@ class GfmAutoComplete { })); }, matcher(flag, subtext) { - const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers); const subtextNodes = subtext .split(/\n+/g) .pop() @@ -366,6 +366,27 @@ class GfmAutoComplete { return null; }); + // If any label matches the inserted text after the last `~`, suggest those labels, + // even if any spaces or funky characters were typed. + // This allows matching labels like "Accepting merge requests". + const labels = instance.cachedData[flag]; + if (labels) { + if (!subtext.includes(flag)) { + // Do not match if there is no `~` before the cursor + return null; + } + const lastCandidate = subtext.split(flag).pop(); + if (labels.find(label => label.title.startsWith(lastCandidate))) { + return lastCandidate; + } + } else { + // Load all labels into the autocompleter. + // This needs to happen if e.g. editing a label in an existing comment, because normally + // label data would only be loaded only once you type `~`. + fetchData(this.$inputor, this.at); + } + + const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers); return match && match.length ? match[1] : null; }, filter(query, data, searchKey) { @@ -563,8 +584,9 @@ class GfmAutoComplete { const accentAChar = decodeURI('%C3%80'); const accentYChar = decodeURI('%C3%BF'); + // Holy regex, batman! const regexp = new RegExp( - `^(?:\\B|[^a-zA-Z0-9_\`${atSymbolsWithoutBar}]|\\s)${resultantFlag}(?!${atSymbolsWithBar})((?:[A-Za-z${accentAChar}-${accentYChar}0-9_'.+-]|[^\\x00-\\x7a])*)$`, + `^(?:\\B|[^a-zA-Z0-9_\`${atSymbolsWithoutBar}]|\\s)${resultantFlag}(?!${atSymbolsWithBar})((?:[A-Za-z${accentAChar}-${accentYChar}0-9_'.+-:]|[^\\x00-\\x7a])*)$`, 'gi', ); 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/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue index 95782b2c88a..1af86a94482 100644 --- a/app/assets/javascripts/ide/components/ide_tree_list.vue +++ b/app/assets/javascripts/ide/components/ide_tree_list.vue @@ -30,6 +30,9 @@ export default { showLoading() { return !this.currentTree || this.currentTree.loading; }, + actualTreeList() { + return this.currentTree.tree.filter(entry => !entry.moved); + }, }, mounted() { this.updateViewer(this.viewerType); @@ -54,9 +57,9 @@ export default { <slot name="header"></slot> </header> <div class="ide-tree-body h-100"> - <template v-if="currentTree.tree.length"> + <template v-if="actualTreeList.length"> <file-row - v-for="file in currentTree.tree" + v-for="file in actualTreeList" :key="file.key" :file="file" :level="0" 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 5429b834708..8c0119a1fed 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -8,6 +8,7 @@ import * as types from './mutation_types'; import { decorateFiles } from '../lib/files'; import { stageKeys } from '../constants'; import service from '../services'; +import router from '../ide_router'; export const redirectToUrl = (self, url) => visitUrl(url); @@ -61,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', @@ -207,10 +208,7 @@ export const deleteEntry = ({ commit, dispatch, state }, path) => { } commit(types.DELETE_ENTRY, path); - - if (entry.parentPath && state.entries[entry.parentPath].tree.length === 0) { - dispatch('deleteEntry', entry.parentPath); - } + dispatch('stageChange', path); dispatch('triggerFilesChange'); }; @@ -238,10 +236,15 @@ export const renameEntry = ( parentPath: newParentPath, }); }); - } + } else { + const newPath = parentPath ? `${parentPath}/${name}` : name; + const newEntry = state.entries[newPath]; + commit(types.TOGGLE_FILE_CHANGED, { file: newEntry, changed: true }); - if (!entryPath && !entry.tempFile) { - dispatch('deleteEntry', path); + if (entry.opened) { + router.push(`/project${newEntry.url}`); + commit(types.TOGGLE_FILE_OPEN, entry.path); + } } dispatch('triggerFilesChange'); diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index dc40a1fa6a2..7627b6e03af 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -73,7 +73,9 @@ export const getFileData = ( .getFileData(joinPaths(gon.relative_url_root || '', url.replace('/-/', '/'))) .then(({ data, headers }) => { const normalizedHeaders = normalizeHeaders(headers); - setPageTitle(decodeURI(normalizedHeaders['PAGE-TITLE'])); + let title = normalizedHeaders['PAGE-TITLE']; + title = file.prevPath ? title.replace(file.prevPath, file.path) : title; + setPageTitle(decodeURI(title)); if (data) commit(types.SET_FILE_DATA, { data, file }); if (openFile) commit(types.TOGGLE_FILE_OPEN, path); 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 ae42b87c9a7..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, @@ -216,15 +229,16 @@ export default { Vue.set(state.entries, newPath, { ...oldEntry, id: newPath, - key: `${newPath}-${oldEntry.type}-${oldEntry.id}`, + key: `${newPath}-${oldEntry.type}-${oldEntry.path}`, path: newPath, name: entryPath ? oldEntry.name : name, tempFile: true, prevPath: oldEntry.tempFile ? null : oldEntry.path, url: oldEntry.url.replace(new RegExp(`${oldEntry.path}/?$`), newPath), tree: [], - parentPath, raw: '', + opened: false, + parentPath, }); oldEntry.moved = true; @@ -241,10 +255,6 @@ export default { state.changedFiles = state.changedFiles.concat(newEntry); } - if (state.entries[newPath].opened) { - state.openFiles.push(state.entries[newPath]); - } - if (oldEntry.tempFile) { const filterMethod = f => f.path !== oldEntry.path; 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 4e7a8765abe..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; } @@ -147,11 +148,12 @@ export const createCommitPayload = ({ commit_message: state.commitMessage || getters.preBuiltCommitMessage, actions: getCommitFiles(rootState.stagedFiles).map(f => ({ action: commitActionForFile(f), - file_path: f.path, + file_path: f.moved ? f.movedPath : f.path, previous_path: f.prevPath === '' ? undefined : f.prevPath, - content: f.content || undefined, + 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/ide/utils.js b/app/assets/javascripts/ide/utils.js index d895eca7af0..ae579fef25f 100644 --- a/app/assets/javascripts/ide/utils.js +++ b/app/assets/javascripts/ide/utils.js @@ -3,7 +3,7 @@ import { commitItemIconMap } from './constants'; export const getCommitIconMap = file => { if (file.deleted) { return commitItemIconMap.deleted; - } else if (file.tempFile) { + } else if (file.tempFile && !file.prevPath) { return commitItemIconMap.addition; } diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue index 42a3de62772..b2f9296c68b 100644 --- a/app/assets/javascripts/issue_show/components/edit_actions.vue +++ b/app/assets/javascripts/issue_show/components/edit_actions.vue @@ -73,7 +73,9 @@ export default { Save changes <i v-if="formState.updateLoading" class="fa fa-spinner fa-spin" aria-hidden="true"> </i> </button> - <button class="btn btn-default float-right" type="button" @click="closeForm">Cancel</button> + <button class="btn btn-default float-right" type="button" @click="closeForm"> + {{ __('Cancel') }} + </button> <button v-if="shouldShowDeleteButton" :class="{ disabled: deleteLoading }" diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue index d27dd873125..447d7bf21a5 100644 --- a/app/assets/javascripts/issue_show/components/fields/description.vue +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -39,7 +39,7 @@ export default { <template> <div class="common-note-form"> - <label class="sr-only" for="issue-description"> Description </label> + <label class="sr-only" for="issue-description">{{ __('Description') }}</label> <markdown-field :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" @@ -55,8 +55,8 @@ export default { qa-description-textarea" dir="auto" data-supports-quick-actions="false" - aria-label="Description" - placeholder="Write a comment or drag your files here…" + :aria-label="__('Description')" + :placeholder="__('Write a comment or drag your files here…')" @keydown.meta.enter="updateIssuable" @keydown.ctrl.enter="updateIssuable" > diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue index 14f0acf6540..6f955928d8e 100644 --- a/app/assets/javascripts/issue_show/components/fields/description_template.vue +++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue @@ -56,22 +56,31 @@ export default { data-selected="null" data-toggle="dropdown" > - <span class="dropdown-toggle-text"> Choose a template </span> + <span class="dropdown-toggle-text">{{ __('Choose a template') }}</span> <i aria-hidden="true" class="fa fa-chevron-down"> </i> </button> <div class="dropdown-menu dropdown-select"> <div class="dropdown-title"> Choose a template - <button class="dropdown-title-button dropdown-menu-close" aria-label="Close" type="button"> + <button + class="dropdown-title-button dropdown-menu-close" + :aria-label="__('Close')" + type="button" + > <i aria-hidden="true" class="fa fa-times dropdown-menu-close-icon"> </i> </button> </div> <div class="dropdown-input"> - <input type="search" class="dropdown-input-field" placeholder="Filter" autocomplete="off" /> + <input + type="search" + class="dropdown-input-field" + :placeholder="__('Filter')" + autocomplete="off" + /> <i aria-hidden="true" class="fa fa-search dropdown-input-search"> </i> <i role="button" - aria-label="Clear templates search input" + :aria-label="__('Clear templates search input')" class="fa fa-times dropdown-input-clear js-dropdown-input-clear" > </i> @@ -79,8 +88,12 @@ export default { <div class="dropdown-content"></div> <div class="dropdown-footer"> <ul class="dropdown-footer-list"> - <li><a class="no-template"> No template </a></li> - <li><a class="reset-template"> Reset template </a></li> + <li> + <a class="no-template">{{ __('No template') }}</a> + </li> + <li> + <a class="reset-template">{{ __('Reset template') }}</a> + </li> </ul> </div> </div> diff --git a/app/assets/javascripts/issue_show/components/fields/title.vue b/app/assets/javascripts/issue_show/components/fields/title.vue index ce4baf17d09..34eb0451d53 100644 --- a/app/assets/javascripts/issue_show/components/fields/title.vue +++ b/app/assets/javascripts/issue_show/components/fields/title.vue @@ -14,7 +14,7 @@ export default { <template> <fieldset> - <label class="sr-only" for="issuable-title"> Title </label> + <label class="sr-only" for="issuable-title">{{ __('Title') }}</label> <input id="issuable-title" ref="input" @@ -22,8 +22,8 @@ export default { class="form-control qa-title-input" dir="auto" type="text" - placeholder="Title" - aria-label="Title" + :placeholder="__('Title')" + :aria-label="__('Title')" @keydown.meta.enter="updateIssuable" @keydown.ctrl.enter="updateIssuable" /> diff --git a/app/assets/javascripts/issue_show/components/locked_warning.vue b/app/assets/javascripts/issue_show/components/locked_warning.vue index 639221473b1..2f3e611e089 100644 --- a/app/assets/javascripts/issue_show/components/locked_warning.vue +++ b/app/assets/javascripts/issue_show/components/locked_warning.vue @@ -10,8 +10,9 @@ export default { <template> <div class="alert alert-danger"> - Someone edited the issue at the same time you did. Please check out - <a :href="currentPath" target="_blank" rel="nofollow">the issue</a> and make sure your changes - will not unintentionally remove theirs. + {{ sprintf(__("Someone edited the issue at the same time you did. Please check out + %{linkStart}%the issue%{linkEnd} and make sure your changes will not unintentionally remove + theirs."), { linkStart: `<a href="${currentPath}" target="_blank" rel="nofollow">` linkEnd: '</a + >', }) }} </div> </template> diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue index 607b2bd1c74..156735441ca 100644 --- a/app/assets/javascripts/jobs/components/job_log_controllers.vue +++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue @@ -3,7 +3,7 @@ import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui'; import { polyfillSticky } from '~/lib/utils/sticky'; import Icon from '~/vue_shared/components/icon.vue'; import { numberToHumanSize } from '~/lib/utils/number_utils'; -import { sprintf } from '~/locale'; +import { __, sprintf } from '~/locale'; import scrollDown from '../svg/scroll_down.svg'; export default { @@ -50,7 +50,7 @@ export default { }, computed: { jobLogSize() { - return sprintf('Showing last %{size} of log -', { + return sprintf(__('Showing last %{size} of log -'), { size: numberToHumanSize(this.size), }); }, @@ -74,14 +74,12 @@ export default { <div class="js-truncated-info truncated-info d-none d-sm-block float-left"> <template v-if="isTraceSizeVisible"> {{ jobLogSize }} - <gl-link v-if="rawPath" :href="rawPath" class="js-raw-link text-plain text-underline prepend-left-5" + >{{ s__('Job|Complete Raw') }}</gl-link > - {{ s__('Job|Complete Raw') }} - </gl-link> </template> </div> <!-- eo truncate information --> diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue index 24276c06486..e9704584c9f 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -1,4 +1,5 @@ <script> +import { __, sprintf } from '~/locale'; import _ from 'underscore'; import { mapActions, mapState } from 'vuex'; import { GlLink, GlButton } from '@gitlab/ui'; @@ -63,7 +64,9 @@ export default { let t = this.job.metadata.timeout_human_readable; if (this.job.metadata.timeout_source !== '') { - t += ` (from ${this.job.metadata.timeout_source})`; + t += sprintf(__(` (from %{timeoutSource})`), { + timeoutSource: this.job.metadata.timeout_source, + }); } return t; 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/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index d521c462ad8..062d21ed247 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -479,9 +479,13 @@ export const pikadayToString = date => { * Seconds can be negative or positive, zero or non-zero. Can be configured for any day * or week length. */ -export const parseSeconds = (seconds, { daysPerWeek = 5, hoursPerDay = 8 } = {}) => { +export const parseSeconds = ( + seconds, + { daysPerWeek = 5, hoursPerDay = 8, limitToHours = false } = {}, +) => { const DAYS_PER_WEEK = daysPerWeek; const HOURS_PER_DAY = hoursPerDay; + const SECONDS_PER_MINUTE = 60; const MINUTES_PER_HOUR = 60; const MINUTES_PER_WEEK = DAYS_PER_WEEK * HOURS_PER_DAY * MINUTES_PER_HOUR; const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR; @@ -493,9 +497,18 @@ export const parseSeconds = (seconds, { daysPerWeek = 5, hoursPerDay = 8 } = {}) minutes: 1, }; - let unorderedMinutes = Math.abs(seconds / MINUTES_PER_HOUR); + if (limitToHours) { + timePeriodConstraints.weeks = 0; + timePeriodConstraints.days = 0; + } + + let unorderedMinutes = Math.abs(seconds / SECONDS_PER_MINUTE); return _.mapObject(timePeriodConstraints, minutesPerPeriod => { + if (minutesPerPeriod === 0) { + return 0; + } + const periodCount = Math.floor(unorderedMinutes / minutesPerPeriod); unorderedMinutes -= periodCount * minutesPerPeriod; 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 new file mode 100644 index 00000000000..012d1e70410 --- /dev/null +++ b/app/assets/javascripts/manual_ordering.js @@ -0,0 +1,58 @@ +import Sortable from 'sortablejs'; +import { s__ } from '~/locale'; +import createFlash from '~/flash'; +import { + getBoardSortableDefaultOptions, + sortableStart, +} from '~/boards/mixins/sortable_default_options'; +import axios from '~/lib/utils/axios_utils'; + +const updateIssue = (url, issueList, { move_before_id, move_after_id }) => + axios + .put(`${url}/reorder`, { + move_before_id, + move_after_id, + group_full_path: issueList.dataset.groupFullPath, + }) + .catch(() => { + createFlash(s__("ManualOrdering|Couldn't save the order of the issues")); + }); + +const initManualOrdering = () => { + const issueList = document.querySelector('.manual-ordering'); + + if (!issueList || !(gon.features && gon.features.manualSorting) || !(gon.current_user_id > 0)) { + return; + } + + Sortable.create( + issueList, + getBoardSortableDefaultOptions({ + scroll: true, + dataIdAttr: 'data-id', + fallbackOnBody: false, + group: { + name: 'issues', + }, + draggable: 'li.issue', + onStart: () => { + sortableStart(); + }, + onUpdate: event => { + const el = event.item; + + const url = el.getAttribute('url'); + + const prev = el.previousElementSibling; + const next = el.nextElementSibling; + + const beforeId = prev && parseInt(prev.dataset.id, 10); + const afterId = next && parseInt(next.dataset.id, 10); + + updateIssue(url, issueList, { move_after_id: afterId, move_before_id: beforeId }); + }, + }), + ); +}; + +export default initManualOrdering; diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue index 9de4e96e4da..81773bd140e 100644 --- a/app/assets/javascripts/monitoring/components/charts/area.vue +++ b/app/assets/javascripts/monitoring/components/charts/area.vue @@ -1,4 +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'; @@ -13,6 +15,7 @@ export default { components: { GlAreaChart, GlChartSeriesLabel, + GlLink, Icon, }, inheritAttrs: false, @@ -43,6 +46,10 @@ export default { required: false, default: () => [], }, + projectPath: { + type: String, + required: true, + }, thresholds: { type: Array, required: false, @@ -54,6 +61,7 @@ export default { tooltip: { title: '', content: [], + commitUrl: '', isDeployment: false, sha: '', }, @@ -99,7 +107,7 @@ export default { chartOptions() { return { xAxis: { - name: 'Time', + name: __('Time'), type: 'time', axisLabel: { formatter: date => dateFormat(date, 'h:MM TT'), @@ -194,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 @@ -258,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/charts/column.vue b/app/assets/javascripts/monitoring/components/charts/column.vue new file mode 100644 index 00000000000..05a2036f4c3 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/charts/column.vue @@ -0,0 +1,131 @@ +<script> +import { GlColumnChart } from '@gitlab/ui/dist/charts'; +import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; +import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; +import { chartHeight } from '../../constants'; +import { makeDataSeries } from '~/helpers/monitor_helper'; + +export default { + components: { + GlColumnChart, + }, + inheritAttrs: false, + props: { + graphData: { + type: Object, + required: true, + validator(data) { + return ( + Array.isArray(data.queries) && + data.queries.filter(query => { + if (Array.isArray(query.result)) { + return ( + query.result.filter(res => Array.isArray(res.values)).length === query.result.length + ); + } + return false; + }).length === data.queries.length + ); + }, + containerWidth: { + type: Number, + required: true, + }, + }, + }, + data() { + return { + width: 0, + height: chartHeight, + svgs: {}, + debouncedResizeCallback: {}, + }; + }, + computed: { + chartData() { + const queryData = this.graphData.queries.reduce((acc, query) => { + const series = makeDataSeries(query.result, { + name: this.formatLegendLabel(query), + }); + + return acc.concat(series); + }, []); + + return { + values: queryData[0].data, + }; + }, + xAxisTitle() { + return this.graphData.queries[0].result[0].x_label !== undefined + ? this.graphData.queries[0].result[0].x_label + : ''; + }, + yAxisTitle() { + return this.graphData.queries[0].result[0].y_label !== undefined + ? this.graphData.queries[0].result[0].y_label + : ''; + }, + xAxisType() { + return this.graphData.x_type !== undefined ? this.graphData.x_type : 'category'; + }, + dataZoomConfig() { + const handleIcon = this.svgs['scroll-handle']; + + return handleIcon ? { handleIcon } : {}; + }, + chartOptions() { + return { + dataZoom: this.dataZoomConfig, + }; + }, + }, + watch: { + containerWidth: 'onResize', + }, + beforeDestroy() { + window.removeEventListener('resize', this.debouncedResizeCallback); + }, + created() { + this.debouncedResizeCallback = debounceByAnimationFrame(this.onResize); + window.addEventListener('resize', this.debouncedResizeCallback); + this.setSvg('scroll-handle'); + }, + methods: { + formatLegendLabel(query) { + return `${query.label}`; + }, + onResize() { + const { width } = this.$refs.columnChart.$el.getBoundingClientRect(); + this.width = width; + }, + setSvg(name) { + getSvgIconPathContent(name) + .then(path => { + if (path) { + this.$set(this.svgs, name, `path://${path}`); + } + }) + .catch(() => {}); + }, + }, +}; +</script> +<template> + <div class="prometheus-graph col-12 col-lg-6"> + <div class="prometheus-graph-header"> + <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5> + <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div> + </div> + <gl-column-chart + ref="columnChart" + v-bind="$attrs" + :data="chartData" + :option="chartOptions" + :width="width" + :height="height" + :x-axis-title="xAxisTitle" + :y-axis-title="yAxisTitle" + :x-axis-type="xAxisType" + /> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 0a652329dfe..2cbda8ea05d 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -106,17 +106,24 @@ export default { }, customMetricsPath: { type: String, - required: true, + required: false, + default: invalidUrl, }, validateQueryPath: { type: String, - required: true, + required: false, + default: invalidUrl, }, dashboardEndpoint: { type: String, required: false, default: invalidUrl, }, + currentDashboard: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -139,10 +146,15 @@ export default { 'deploymentData', 'metricsWithData', 'useDashboardEndpoint', + 'allDashboards', + 'multipleDashboardsEnabled', ]), groupsWithData() { return this.groups.filter(group => this.chartsWithData(group.metrics).length > 0); }, + selectedDashboardText() { + return this.currentDashboard || (this.allDashboards[0] && this.allDashboards[0].display_name); + }, }, created() { this.setEndpoints({ @@ -150,6 +162,7 @@ export default { environmentsEndpoint: this.environmentsEndpoint, deploymentsEndpoint: this.deploymentsEndpoint, dashboardEndpoint: this.dashboardEndpoint, + currentDashboard: this.currentDashboard, }); this.timeWindows = timeWindows; @@ -234,12 +247,30 @@ export default { </script> <template> - <div v-if="!showEmptyState" class="prometheus-graphs"> + <div class="prometheus-graphs"> <div class="gl-p-3 border-bottom bg-gray-light d-flex justify-content-between"> <div v-if="environmentsEndpoint" class="dropdowns d-flex align-items-center justify-content-between" > + <div v-if="multipleDashboardsEnabled" class="d-flex align-items-center"> + <label class="mb-0">{{ __('Dashboard') }}</label> + <gl-dropdown + class="ml-2 mr-3 js-dashboards-dropdown" + toggle-class="dropdown-menu-toggle" + :text="selectedDashboardText" + > + <gl-dropdown-item + v-for="dashboard in allDashboards" + :key="dashboard.path" + :active="dashboard.path === currentDashboard" + active-class="is-active" + :href="`?dashboard=${dashboard.path}`" + > + {{ dashboard.display_name || dashboard.path }} + </gl-dropdown-item> + </gl-dropdown> + </div> <div class="d-flex align-items-center"> <strong>{{ s__('Metrics|Environment') }}</strong> <gl-dropdown @@ -253,11 +284,12 @@ export default { :key="environment.id" :active="environment.name === currentEnvironmentName" active-class="is-active" + :href="environment.metrics_path" >{{ environment.name }}</gl-dropdown-item > </gl-dropdown> </div> - <div class="d-flex align-items-center prepend-left-8"> + <div v-if="!showEmptyState" class="d-flex align-items-center prepend-left-8"> <strong>{{ s__('Metrics|Show last') }}</strong> <gl-dropdown class="prepend-left-10 js-time-window-dropdown" @@ -276,7 +308,7 @@ export default { </div> </div> <div class="d-flex"> - <div v-if="isEE && canAddMetrics"> + <div v-if="isEE && canAddMetrics && !showEmptyState"> <gl-button v-gl-modal-directive="$options.addMetric.modalId" class="js-add-metric-button text-success border-success" @@ -317,40 +349,43 @@ export default { </gl-button> </div> </div> - <graph-group - v-for="(groupData, index) in groupsWithData" - :key="index" - :name="groupData.group" - :show-panels="showPanels" - > - <monitor-area-chart - v-for="(graphData, graphIndex) in chartsWithData(groupData.metrics)" - :key="graphIndex" - :graph-data="graphData" - :deployment-data="deploymentData" - :thresholds="getGraphAlertValues(graphData.queries)" - :container-width="elWidth" - group-id="monitor-area-chart" + <div v-if="!showEmptyState"> + <graph-group + v-for="(groupData, index) in groupsWithData" + :key="index" + :name="groupData.group" + :show-panels="showPanels" > - <alert-widget - v-if="isEE && prometheusAlertsAvailable && alertsEndpoint && graphData" - :alerts-endpoint="alertsEndpoint" - :relevant-queries="graphData.queries" - :alerts-to-manage="getGraphAlerts(graphData.queries)" - @setAlerts="setAlerts" - /> - </monitor-area-chart> - </graph-group> + <monitor-area-chart + v-for="(graphData, graphIndex) in chartsWithData(groupData.metrics)" + :key="graphIndex" + :project-path="projectPath" + :graph-data="graphData" + :deployment-data="deploymentData" + :thresholds="getGraphAlertValues(graphData.queries)" + :container-width="elWidth" + group-id="monitor-area-chart" + > + <alert-widget + v-if="isEE && prometheusAlertsAvailable && alertsEndpoint && graphData" + :alerts-endpoint="alertsEndpoint" + :relevant-queries="graphData.queries" + :alerts-to-manage="getGraphAlerts(graphData.queries)" + @setAlerts="setAlerts" + /> + </monitor-area-chart> + </graph-group> + </div> + <empty-state + v-else + :selected-state="emptyState" + :documentation-path="documentationPath" + :settings-path="settingsPath" + :clusters-path="clustersPath" + :empty-getting-started-svg-path="emptyGettingStartedSvgPath" + :empty-loading-svg-path="emptyLoadingSvgPath" + :empty-no-data-svg-path="emptyNoDataSvgPath" + :empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath" + /> </div> - <empty-state - v-else - :selected-state="emptyState" - :documentation-path="documentationPath" - :settings-path="settingsPath" - :clusters-path="clustersPath" - :empty-getting-started-svg-path="emptyGettingStartedSvgPath" - :empty-loading-svg-path="emptyLoadingSvgPath" - :empty-no-data-svg-path="emptyNoDataSvgPath" - :empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath" - /> </template> diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue index 0e141d02ead..a3c6de14aa4 100644 --- a/app/assets/javascripts/monitoring/components/empty_state.vue +++ b/app/assets/javascripts/monitoring/components/empty_state.vue @@ -1,4 +1,6 @@ <script> +import { __ } from '~/locale'; + export default { props: { documentationPath: { @@ -41,35 +43,35 @@ export default { states: { gettingStarted: { svgUrl: this.emptyGettingStartedSvgPath, - title: 'Get started with performance monitoring', - description: `Stay updated about the performance and health - of your environment by configuring Prometheus to monitor your deployments.`, - buttonText: 'Install on clusters', + title: __('Get started with performance monitoring'), + description: __(`Stay updated about the performance and health + of your environment by configuring Prometheus to monitor your deployments.`), + buttonText: __('Install on clusters'), buttonPath: this.clustersPath, - secondaryButtonText: 'Configure existing installation', + secondaryButtonText: __('Configure existing installation'), secondaryButtonPath: this.settingsPath, }, loading: { svgUrl: this.emptyLoadingSvgPath, - title: 'Waiting for performance data', - description: `Creating graphs uses the data from the Prometheus server. - If this takes a long time, ensure that data is available.`, - buttonText: 'View documentation', + title: __('Waiting for performance data'), + description: __(`Creating graphs uses the data from the Prometheus server. + If this takes a long time, ensure that data is available.`), + buttonText: __('View documentation'), buttonPath: this.documentationPath, }, noData: { svgUrl: this.emptyNoDataSvgPath, - title: 'No data found', - description: `You are connected to the Prometheus server, but there is currently - no data to display.`, - buttonText: 'Configure Prometheus', + title: __('No data found'), + description: __(`You are connected to the Prometheus server, but there is currently + no data to display.`), + buttonText: __('Configure Prometheus'), buttonPath: this.settingsPath, }, unableToConnect: { svgUrl: this.emptyUnableToConnectSvgPath, - title: 'Unable to connect to Prometheus server', + title: __('Unable to connect to Prometheus server'), description: 'Ensure connectivity is available from the GitLab server to the ', - buttonText: 'View documentation', + buttonText: __('View documentation'), buttonPath: this.documentationPath, }, }, @@ -90,7 +92,9 @@ export default { <template> <div class="row empty-state js-empty-state"> <div class="col-12"> - <div class="state-svg svg-content"><img :src="currentState.svgUrl" /></div> + <div class="state-svg svg-content"> + <img :src="currentState.svgUrl" /> + </div> </div> <div class="col-12"> @@ -98,20 +102,22 @@ export default { <h4 class="state-title text-center">{{ currentState.title }}</h4> <p class="state-description"> {{ currentState.description }} - <a v-if="showButtonDescription" :href="settingsPath"> Prometheus server </a> + <a v-if="showButtonDescription" :href="settingsPath">{{ __('Prometheus server') }}</a> </p> <div class="text-center"> - <a v-if="currentState.buttonPath" :href="currentState.buttonPath" class="btn btn-success"> - {{ currentState.buttonText }} - </a> + <a + v-if="currentState.buttonPath" + :href="currentState.buttonPath" + class="btn btn-success" + >{{ currentState.buttonText }}</a + > <a v-if="currentState.secondaryButtonPath" :href="currentState.secondaryButtonPath" class="btn" + >{{ currentState.secondaryButtonText }}</a > - {{ currentState.secondaryButtonText }} - </a> </div> </div> </div> diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index 1d33537b3b2..97d149e9ad5 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; +import { getParameterValues } from '~/lib/utils/url_utility'; import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue'; import store from './stores'; @@ -7,10 +8,14 @@ export default (props = {}) => { const el = document.getElementById('prometheus-graphs'); if (el && el.dataset) { - store.dispatch( - 'monitoringDashboard/setDashboardEnabled', - gon.features.environmentMetricsUsePrometheusEndpoint, - ); + if (gon.features) { + store.dispatch('monitoringDashboard/setFeatureFlags', { + prometheusEndpointEnabled: gon.features.environmentMetricsUsePrometheusEndpoint, + multipleDashboardsEnabled: gon.features.environmentMetricsShowMultipleDashboards, + }); + } + + const [currentDashboard] = getParameterValues('dashboard'); // eslint-disable-next-line no-new new Vue({ @@ -20,6 +25,7 @@ export default (props = {}) => { return createElement(Dashboard, { props: { ...el.dataset, + currentDashboard, hasMetrics: parseBoolean(el.dataset.hasMetrics), ...props, }, diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index f41e215cb5d..0fa2a5d6370 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -35,14 +35,24 @@ export const setEndpoints = ({ commit }, endpoints) => { commit(types.SET_ENDPOINTS, endpoints); }; -export const setDashboardEnabled = ({ commit }, enabled) => { - commit(types.SET_DASHBOARD_ENABLED, enabled); +export const setFeatureFlags = ( + { commit }, + { prometheusEndpointEnabled, multipleDashboardsEnabled }, +) => { + commit(types.SET_DASHBOARD_ENABLED, prometheusEndpointEnabled); + commit(types.SET_MULTIPLE_DASHBOARDS_ENABLED, multipleDashboardsEnabled); }; export const requestMetricsDashboard = ({ commit }) => { commit(types.REQUEST_METRICS_DATA); }; -export const receiveMetricsDashboardSuccess = ({ commit, dispatch }, { response, params }) => { +export const receiveMetricsDashboardSuccess = ( + { state, commit, dispatch }, + { response, params }, +) => { + if (state.multipleDashboardsEnabled) { + commit(types.SET_ALL_DASHBOARDS, response.all_dashboards); + } commit(types.RECEIVE_METRICS_DATA_SUCCESS, response.dashboard.panel_groups); dispatch('fetchPrometheusMetrics', params); }; @@ -95,6 +105,11 @@ export const fetchMetricsData = ({ state, dispatch }, params) => { export const fetchDashboard = ({ state, dispatch }, params) => { dispatch('requestMetricsDashboard'); + if (state.currentDashboard) { + // eslint-disable-next-line no-param-reassign + params.dashboard = state.currentDashboard; + } + return axios .get(state.dashboardEndpoint, { params }) .then(resp => resp.data) diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index 63894e83362..2c78a0b9315 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -10,6 +10,8 @@ export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAIL export const SET_QUERY_RESULT = 'SET_QUERY_RESULT'; export const SET_TIME_WINDOW = 'SET_TIME_WINDOW'; export const SET_DASHBOARD_ENABLED = 'SET_DASHBOARD_ENABLED'; +export const SET_MULTIPLE_DASHBOARDS_ENABLED = 'SET_MULTIPLE_DASHBOARDS_ENABLED'; +export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS'; export const SET_ENDPOINTS = 'SET_ENDPOINTS'; export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE'; export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index d4b816e2717..a85a7723c1f 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -74,10 +74,14 @@ export default { state.environmentsEndpoint = endpoints.environmentsEndpoint; state.deploymentsEndpoint = endpoints.deploymentsEndpoint; state.dashboardEndpoint = endpoints.dashboardEndpoint; + state.currentDashboard = endpoints.currentDashboard; }, [types.SET_DASHBOARD_ENABLED](state, enabled) { state.useDashboardEndpoint = enabled; }, + [types.SET_MULTIPLE_DASHBOARDS_ENABLED](state, enabled) { + state.multipleDashboardsEnabled = enabled; + }, [types.SET_GETTING_STARTED_EMPTY_STATE](state) { state.emptyState = 'gettingStarted'; }, @@ -85,4 +89,7 @@ export default { state.showEmptyState = true; state.emptyState = 'noData'; }, + [types.SET_ALL_DASHBOARDS](state, dashboards) { + state.allDashboards = dashboards; + }, }; diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index c33529cd588..de711d6ccae 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -8,10 +8,13 @@ export default () => ({ deploymentsEndpoint: null, dashboardEndpoint: invalidUrl, useDashboardEndpoint: false, + multipleDashboardsEnabled: false, emptyState: 'gettingStarted', showEmptyState: true, groups: [], deploymentData: [], environments: [], metricsWithData: [], + allDashboards: [], + currentDashboard: null, }); diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 075c28e8d07..5a4b5f9398b 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -65,7 +65,7 @@ export default { return this.getUserData.id; }, commentButtonTitle() { - return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion'; + return this.noteType === constants.COMMENT ? 'Comment' : 'Start thread'; }, startDiscussionDescription() { let text = 'Discuss a specific suggestion or question'; @@ -418,7 +418,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" > <i aria-hidden="true" class="fa fa-check icon"> </i> <div class="description"> - <strong>Start discussion</strong> + <strong>Start thread</strong> <p>{{ startDiscussionDescription }}</p> </div> </button> 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_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index efd84f5722c..d7ffa0abb79 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -61,7 +61,7 @@ export default { </span> <span class="line-resolve-text"> {{ resolvedDiscussionsCount }}/{{ resolvableDiscussionsCount }} - {{ n__('discussion resolved', 'discussions resolved', resolvableDiscussionsCount) }} + {{ n__('thread resolved', 'threads resolved', resolvableDiscussionsCount) }} </span> </div> <div @@ -72,7 +72,7 @@ export default { <a v-gl-tooltip :href="resolveAllDiscussionsIssuePath" - :title="s__('Resolve all discussions in new issue')" + :title="s__('Resolve all threads in new issue')" class="new-issue-for-discussion btn btn-default discussion-create-issue-btn" > <icon name="issue-new" /> @@ -81,7 +81,7 @@ export default { <div v-if="isLoggedIn && !allResolved" class="btn-group btn-group-sm" role="group"> <button v-gl-tooltip - title="Jump to first unresolved discussion" + title="Jump to first unresolved thread" class="btn btn-default discussion-next-btn" @click="jumpToFirstUnresolvedDiscussion" > 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/discussion_resolve_with_issue_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue index e413398696a..f03e6fd73d7 100644 --- a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue +++ b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue @@ -25,7 +25,7 @@ export default { <gl-button v-gl-tooltip :href="url" - :title="s__('MergeRequests|Resolve this discussion in a new issue')" + :title="s__('MergeRequests|Resolve this thread in a new issue')" class="new-issue-for-discussion discussion-create-issue-btn" > <icon name="issue-new" /> diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 042ed196933..01be4f2b094 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -283,11 +283,11 @@ export default { type="checkbox" class="qa-unresolve-review-discussion" /> - {{ __('Unresolve discussion') }} + {{ __('Unresolve thread') }} </template> <template v-else> <input v-model="isResolving" type="checkbox" class="qa-resolve-review-discussion" /> - {{ __('Resolve discussion') }} + {{ __('Resolve thread') }} </template> </label> </p> diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index fbf82fab9e9..6466ab3acbe 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -70,7 +70,7 @@ export default { @click="handleToggle" > <i :class="toggleChevronClass" class="fa" aria-hidden="true"></i> - {{ __('Toggle discussion') }} + {{ __('Toggle thread') }} </button> </div> <a diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 10b15a9c38c..a71a89cfffc 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'; @@ -177,22 +174,20 @@ export default { active: isActive, } = this.discussion; - let text = s__('MergeRequests|started a discussion'); + let text = s__('MergeRequests|started a thread'); if (isForCommit) { - text = s__( - 'MergeRequests|started a discussion on commit %{linkStart}%{commitId}%{linkEnd}', - ); + text = s__('MergeRequests|started a thread on commit %{linkStart}%{commitId}%{linkEnd}'); } else if (isDiffDiscussion && commitId) { text = isActive - ? s__('MergeRequests|started a discussion on commit %{linkStart}%{commitId}%{linkEnd}') + ? s__('MergeRequests|started a thread on commit %{linkStart}%{commitId}%{linkEnd}') : s__( - 'MergeRequests|started a discussion on an outdated change in commit %{linkStart}%{commitId}%{linkEnd}', + 'MergeRequests|started a thread on an outdated change in commit %{linkStart}%{commitId}%{linkEnd}', ); } else if (isDiffDiscussion) { text = isActive - ? s__('MergeRequests|started a discussion on %{linkStart}the diff%{linkEnd}') + ? s__('MergeRequests|started a thread on %{linkStart}the diff%{linkEnd}') : s__( - 'MergeRequests|started a discussion on %{linkStart}an old version of the diff%{linkEnd}', + 'MergeRequests|started a thread on %{linkStart}an old version of the diff%{linkEnd}', ); } @@ -253,6 +248,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 +366,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 +378,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/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js index 2329727bca2..16b7598ee09 100644 --- a/app/assets/javascripts/notes/mixins/resolvable.js +++ b/app/assets/javascripts/notes/mixins/resolvable.js @@ -20,13 +20,13 @@ export default { resolveButtonTitle() { if (this.updatedNoteBody) { if (this.discussionResolved) { - return __('Comment & unresolve discussion'); + return __('Comment & unresolve thread'); } - return __('Comment & resolve discussion'); + return __('Comment & resolve thread'); } - return this.discussionResolved ? __('Unresolve discussion') : __('Resolve discussion'); + return this.discussionResolved ? __('Unresolve thread') : __('Resolve thread'); }, }, methods: { 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/dashboard/issues/index.js b/app/assets/javascripts/pages/dashboard/issues/index.js index 9055738f86e..2ffeed8a584 100644 --- a/app/assets/javascripts/pages/dashboard/issues/index.js +++ b/app/assets/javascripts/pages/dashboard/issues/index.js @@ -2,6 +2,7 @@ import projectSelect from '~/project_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; import { FILTERED_SEARCH } from '~/pages/constants'; +import initManualOrdering from '~/manual_ordering'; document.addEventListener('DOMContentLoaded', () => { initFilteredSearch({ @@ -10,4 +11,5 @@ document.addEventListener('DOMContentLoaded', () => { }); projectSelect(); + initManualOrdering(); }); diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index 35d4b034654..23fb5656008 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -2,6 +2,7 @@ import projectSelect from '~/project_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import { FILTERED_SEARCH } from '~/pages/constants'; import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; +import initManualOrdering from '~/manual_ordering'; document.addEventListener('DOMContentLoaded', () => { IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); @@ -12,4 +13,5 @@ document.addEventListener('DOMContentLoaded', () => { filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, }); projectSelect(); + initManualOrdering(); }); 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/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js index c34aff02111..c73ebb31eb3 100644 --- a/app/assets/javascripts/pages/projects/issues/index/index.js +++ b/app/assets/javascripts/pages/projects/issues/index/index.js @@ -7,6 +7,7 @@ import initFilteredSearch from '~/pages/search/init_filtered_search'; import { FILTERED_SEARCH } from '~/pages/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants'; import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; +import initManualOrdering from '~/manual_ordering'; document.addEventListener('DOMContentLoaded', () => { IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); @@ -19,4 +20,5 @@ document.addEventListener('DOMContentLoaded', () => { new ShortcutsNavigation(); new UsersSelect(); + initManualOrdering(); }); 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/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js index 7f800d20835..1d8b388e935 100644 --- a/app/assets/javascripts/pages/users/user_tabs.js +++ b/app/assets/javascripts/pages/users/user_tabs.js @@ -18,12 +18,12 @@ import UserOverviewBlock from './user_overview_block'; * * <ul class="nav-links"> * <li class="activity-tab active"> - * <a data-action="activity" data-target="#activity" data-toggle="tab" href="/u/username"> + * <a data-action="activity" data-target="#activity" data-toggle="tab" href="/username"> * Activity * </a> * </li> * <li class="groups-tab"> - * <a data-action="groups" data-target="#groups" data-toggle="tab" href="/u/username/groups"> + * <a data-action="groups" data-target="#groups" data-toggle="tab" href="/users/username/groups"> * Groups * </a> * </li> 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/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js index d67d88c4dba..c8819cf35cf 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js +++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js @@ -1,8 +1,8 @@ import Visibility from 'visibilityjs'; +import PipelineStore from 'ee_else_ce/pipelines/stores/pipeline_store'; import Flash from '../flash'; import Poll from '../lib/utils/poll'; import { __ } from '../locale'; -import PipelineStore from './stores/pipeline_store'; import PipelineService from './services/pipeline_service'; export default class pipelinesMediator { 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/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index 0d4d431855c..67963dc1923 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -36,7 +36,7 @@ export default { to: `/tree/${this.ref}${path}`, }); }, - [{ name: this.projectShortPath, path: '/', to: `/tree/${this.ref}` }], + [{ name: this.projectShortPath, path: '/', to: `/tree/${this.ref}/` }], ); }, }, 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/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 891e3fe9d16..1e66ccbfa29 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -131,7 +131,9 @@ export default { v-for="entry in val" :id="entry.id" :key="`${entry.flatPath}-${entry.id}`" + :project-path="projectPath" :current-path="path" + :name="entry.name" :path="entry.flatPath" :type="entry.type" :url="entry.webUrl" diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 4519f82fc93..3e060e9ecb6 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -1,12 +1,30 @@ <script> -import { GlBadge } from '@gitlab/ui'; +import { GlBadge, GlLink, GlSkeletonLoading } from '@gitlab/ui'; import { visitUrl } from '~/lib/utils/url_utility'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { getIconName } from '../../utils/icon'; import getRefMixin from '../../mixins/get_ref'; +import getCommit from '../../queries/getCommit.query.graphql'; export default { components: { GlBadge, + GlLink, + GlSkeletonLoading, + TimeagoTooltip, + }, + apollo: { + commit: { + query: getCommit, + variables() { + return { + fileName: this.name, + type: this.type, + path: this.currentPath, + projectPath: this.projectPath, + }; + }, + }, }, mixins: [getRefMixin], props: { @@ -14,10 +32,18 @@ export default { type: String, required: true, }, + projectPath: { + type: String, + required: true, + }, currentPath: { type: String, required: true, }, + name: { + type: String, + required: true, + }, path: { type: String, required: true, @@ -37,6 +63,11 @@ export default { default: null, }, }, + data() { + return { + commit: null, + }; + }, computed: { routerLinkTo() { return this.isFolder ? { path: `/tree/${this.ref}/${this.path}` } : null; @@ -73,20 +104,26 @@ export default { </script> <template> - <tr v-once :class="`file_${id}`" class="tree-item" @click="openRow"> + <tr :class="`file_${id}`" class="tree-item" @click="openRow"> <td class="tree-item-file-name"> <i :aria-label="type" role="img" :class="iconName" class="fa fa-fw"></i> <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"> - @ <a href="#" class="commit-sha">{{ shortSha }}</a> + @ <gl-link href="#" class="commit-sha">{{ shortSha }}</gl-link> </template> </td> - <td class="d-none d-sm-table-cell tree-commit"></td> - <td class="tree-time-ago text-right"></td> + <td class="d-none d-sm-table-cell tree-commit"> + <gl-link v-if="commit" :href="commit.commitPath" class="str-truncated-100 tree-commit-link"> + {{ commit.message }} + </gl-link> + <gl-skeleton-loading v-else :lines="1" class="h-auto" /> + </td> + <td class="tree-time-ago text-right"> + <timeago-tooltip v-if="commit" :time="commit.committedDate" tooltip-placement="bottom" /> + <gl-skeleton-loading v-else :lines="1" class="ml-auto h-auto w-50" /> + </td> </tr> </template> diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js index ef147ec15cb..6cb253c8169 100644 --- a/app/assets/javascripts/repository/graphql.js +++ b/app/assets/javascripts/repository/graphql.js @@ -3,6 +3,7 @@ import VueApollo from 'vue-apollo'; import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import createDefaultClient from '~/lib/graphql'; import introspectionQueryResultData from './fragmentTypes.json'; +import { fetchLogsTree } from './log_tree'; Vue.use(VueApollo); @@ -13,7 +14,21 @@ const fragmentMatcher = new IntrospectionFragmentMatcher({ }); const defaultClient = createDefaultClient( - {}, + { + Query: { + commit(_, { path, fileName, type }) { + return new Promise(resolve => { + fetchLogsTree(defaultClient, path, '0', { + resolve, + entry: { + name: fileName, + type, + }, + }); + }); + }, + }, + }, { cacheConfig: { fragmentMatcher, diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index d9216e88676..ea051eaa414 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -16,6 +16,7 @@ export default function setupVueRepositoryList() { projectPath, projectShortPath, ref, + commits: [], }, }); @@ -49,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/log_tree.js b/app/assets/javascripts/repository/log_tree.js new file mode 100644 index 00000000000..2c19aca2397 --- /dev/null +++ b/app/assets/javascripts/repository/log_tree.js @@ -0,0 +1,64 @@ +import axios from '~/lib/utils/axios_utils'; +import getCommits from './queries/getCommits.query.graphql'; +import getProjectPath from './queries/getProjectPath.query.graphql'; +import getRef from './queries/getRef.query.graphql'; + +let fetchpromise; +let resolvers = []; + +export function normalizeData(data) { + return data.map(d => ({ + sha: d.commit.id, + message: d.commit.message, + committedDate: d.commit.committed_date, + commitPath: d.commit_path, + fileName: d.file_name, + type: d.type, + __typename: 'LogTreeCommit', + })); +} + +export function resolveCommit(commits, { resolve, entry }) { + const commit = commits.find(c => c.fileName === entry.name && c.type === entry.type); + + if (commit) { + resolve(commit); + } +} + +export function fetchLogsTree(client, path, offset, resolver = null) { + if (resolver) { + resolvers.push(resolver); + } + + if (fetchpromise) return fetchpromise; + + const { projectPath } = client.readQuery({ query: getProjectPath }); + const { ref } = client.readQuery({ query: getRef }); + + fetchpromise = axios + .get(`${gon.gitlab_url}/${projectPath}/refs/${ref}/logs_tree${path ? `/${path}` : ''}`, { + params: { format: 'json', offset }, + }) + .then(({ data, headers }) => { + const headerLogsOffset = headers['more-logs-offset']; + const { commits } = client.readQuery({ query: getCommits }); + const newCommitData = [...commits, ...normalizeData(data)]; + client.writeQuery({ + query: getCommits, + data: { commits: newCommitData }, + }); + + resolvers.forEach(r => resolveCommit(newCommitData, r)); + + fetchpromise = null; + + if (headerLogsOffset) { + fetchLogsTree(client, path, headerLogsOffset); + } else { + resolvers = []; + } + }); + + return fetchpromise; +} diff --git a/app/assets/javascripts/repository/queries/getCommit.query.graphql b/app/assets/javascripts/repository/queries/getCommit.query.graphql new file mode 100644 index 00000000000..e2a2d831e47 --- /dev/null +++ b/app/assets/javascripts/repository/queries/getCommit.query.graphql @@ -0,0 +1,10 @@ +query getCommit($fileName: String!, $type: String!, $path: String!) { + commit(path: $path, fileName: $fileName, type: $type) @client { + sha + message + committedDate + commitPath + fileName + type + } +} diff --git a/app/assets/javascripts/repository/queries/getCommits.query.graphql b/app/assets/javascripts/repository/queries/getCommits.query.graphql new file mode 100644 index 00000000000..df9e67cc440 --- /dev/null +++ b/app/assets/javascripts/repository/queries/getCommits.query.graphql @@ -0,0 +1,10 @@ +query getCommits { + commits @client { + sha + message + committedDate + commitPath + fileName + type + } +} diff --git a/app/assets/javascripts/repository/queries/getFiles.query.graphql b/app/assets/javascripts/repository/queries/getFiles.query.graphql index ef924fde556..4c24fc4087f 100644 --- a/app/assets/javascripts/repository/queries/getFiles.query.graphql +++ b/app/assets/javascripts/repository/queries/getFiles.query.graphql @@ -1,5 +1,6 @@ fragment TreeEntry on Entry { id + name flatPath type } 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/sidebar/components/time_tracking/comparison_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue index f4d926cd3ec..bc263bc36e4 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue @@ -28,11 +28,16 @@ export default { type: String, required: true, }, + limitToHours: { + type: Boolean, + required: false, + default: false, + }, }, computed: { parsedTimeRemaining() { const diffSeconds = this.timeEstimate - this.timeSpent; - return parseSeconds(diffSeconds); + return parseSeconds(diffSeconds, { limitToHours: this.limitToHours }); }, timeRemainingHumanReadable() { return stringifyTime(this.parsedTimeRemaining); @@ -65,9 +70,6 @@ export default { :title="timeRemainingTooltip" :class="timeRemainingStatusClass" class="compare-meter" - data-toggle="tooltip" - data-placement="top" - role="timeRemainingDisplay" > <gl-progress-bar :value="timeRemainingPercent" :variant="progressBarVariant" /> <div class="compare-display-container"> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue index 8e8b9f19b6e..018b30d2a67 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue @@ -53,6 +53,7 @@ export default { :time-spent="store.totalTimeSpent" :human-time-estimate="store.humanTimeEstimate" :human-time-spent="store.humanTotalTimeSpent" + :limit-to-hours="store.timeTrackingLimitToHours" :root-path="store.rootPath" /> </div> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index d84d5344935..682ca600b6a 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -37,6 +37,10 @@ export default { required: false, default: '', }, + limitToHours: { + type: Boolean, + default: false, + }, rootPath: { type: String, required: true, @@ -129,6 +133,7 @@ export default { :time-spent="timeSpent" :time-spent-human-readable="humanTimeSpent" :time-estimate-human-readable="humanTimeEstimate" + :limit-to-hours="limitToHours" /> <transition name="help-state-toggle"> <time-tracking-help-state v-if="showHelpState" :root-path="rootPath" /> diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js index 1ebdbec7bc9..d934463382f 100644 --- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import timeTracker from './components/time_tracking/time_tracker.vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; export default class SidebarMilestone { constructor() { @@ -7,7 +8,7 @@ export default class SidebarMilestone { if (!el) return; - const { timeEstimate, timeSpent, humanTimeEstimate, humanTimeSpent } = el.dataset; + const { timeEstimate, timeSpent, humanTimeEstimate, humanTimeSpent, limitToHours } = el.dataset; // eslint-disable-next-line no-new new Vue({ @@ -22,6 +23,7 @@ export default class SidebarMilestone { timeSpent: parseInt(timeSpent, 10), humanTimeEstimate, humanTimeSpent, + limitToHours: parseBoolean(limitToHours), rootPath: '/', }, }), diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index 22ac8df9699..643fe6c00b6 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -1,7 +1,7 @@ import { visitUrl } from '../lib/utils/url_utility'; import Flash from '../flash'; import Service from './services/sidebar_service'; -import Store from './stores/sidebar_store'; +import Store from 'ee_else_ce/sidebar/stores/sidebar_store'; import { __ } from '~/locale'; export default class SidebarMediator { diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index 7b8b4c5d856..63c4a2a3f84 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -8,7 +8,7 @@ export default class SidebarStore { } initSingleton(options) { - const { currentUser, rootPath, editable } = options; + const { currentUser, rootPath, editable, timeTrackingLimitToHours } = options; this.currentUser = currentUser; this.rootPath = rootPath; this.editable = editable; @@ -16,6 +16,7 @@ export default class SidebarStore { this.totalTimeSpent = 0; this.humanTimeEstimate = ''; this.humanTimeSpent = ''; + this.timeTrackingLimitToHours = timeTrackingLimitToHours; this.assignees = []; this.isFetching = { assignees: true, diff --git a/app/assets/javascripts/visual_review_toolbar/components/comment.js b/app/assets/javascripts/visual_review_toolbar/components/comment.js index 2fec96d1435..04bfb5e9532 100644 --- a/app/assets/javascripts/visual_review_toolbar/components/comment.js +++ b/app/assets/javascripts/visual_review_toolbar/components/comment.js @@ -1,54 +1,62 @@ import { BLACK, COMMENT_BOX, MUTED, LOGOUT } from './constants'; -import { clearNote, note, postError } from './note'; -import { buttonClearStyles, selectCommentBox, selectCommentButton, selectNote } from './utils'; +import { clearNote, postError } from './note'; +import { + buttonClearStyles, + selectCommentBox, + selectCommentButton, + selectNote, + selectNoteContainer, +} from './utils'; const comment = ` <div> <textarea id="${COMMENT_BOX}" name="${COMMENT_BOX}" rows="3" placeholder="Enter your feedback or idea" class="gitlab-input" aria-required="true"></textarea> - ${note} <p class="gitlab-metadata-note">Additional metadata will be included: browser, OS, current page, user agent, and viewport dimensions.</p> </div> <div class="gitlab-button-wrapper"> - <button class="gitlab-button gitlab-button-secondary" style="${buttonClearStyles}" type="button" id="${LOGOUT}"> Logout </button> + <button class="gitlab-button gitlab-button-secondary" style="${buttonClearStyles}" type="button" id="${LOGOUT}"> Log out </button> <button class="gitlab-button gitlab-button-success" style="${buttonClearStyles}" type="button" id="gitlab-comment-button"> Send feedback </button> </div> `; -const resetCommentBox = () => { - const commentBox = selectCommentBox(); +const resetCommentButton = () => { const commentButton = selectCommentButton(); /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ commentButton.innerText = 'Send feedback'; commentButton.classList.replace('gitlab-button-secondary', 'gitlab-button-success'); commentButton.style.opacity = 1; +}; +const resetCommentBox = () => { + const commentBox = selectCommentBox(); commentBox.style.pointerEvents = 'auto'; commentBox.style.color = BLACK; }; -const resetCommentButton = () => { +const resetCommentText = () => { const commentBox = selectCommentBox(); - const currentNote = selectNote(); - commentBox.value = ''; - currentNote.innerText = ''; }; const resetComment = () => { - resetCommentBox(); resetCommentButton(); + resetCommentBox(); + resetCommentText(); }; -const confirmAndClear = mergeRequestId => { +const confirmAndClear = feedbackInfo => { const commentButton = selectCommentButton(); const currentNote = selectNote(); + const noteContainer = selectNoteContainer(); /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ commentButton.innerText = 'Feedback sent'; - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - currentNote.innerText = `Your comment was successfully posted to merge request #${mergeRequestId}`; - setTimeout(resetComment, 2000); + noteContainer.style.visibility = 'visible'; + currentNote.insertAdjacentHTML('beforeend', feedbackInfo); + + setTimeout(resetComment, 1000); + setTimeout(clearNote, 6000); }; const setInProgressState = () => { @@ -71,6 +79,7 @@ const postComment = ({ innerWidth, innerHeight, projectId, + projectPath, mergeRequestId, mrUrl, token, @@ -86,6 +95,7 @@ const postComment = ({ /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ postError('Your comment appears to be empty.', COMMENT_BOX); resetCommentBox(); + resetCommentButton(); return; } @@ -114,18 +124,24 @@ const postComment = ({ }) .then(response => { if (response.ok) { - confirmAndClear(mergeRequestId); - return; + return response.json(); } throw new Error(`${response.status}: ${response.statusText}`); }) + .then(data => { + const commentId = data.notes[0].id; + const feedbackLink = `${mrUrl}/${projectPath}/merge_requests/${mergeRequestId}#note_${commentId}`; + const feedbackInfo = `Feedback sent. View at <a class="gitlab-link" href="${feedbackLink}">${projectPath} #${mergeRequestId} (comment ${commentId})</a>`; + confirmAndClear(feedbackInfo); + }) .catch(err => { postError( `Your comment could not be sent. Please try again. Error: ${err.message}`, COMMENT_BOX, ); resetCommentBox(); + resetCommentButton(); }); }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/constants.js b/app/assets/javascripts/visual_review_toolbar/components/constants.js index 32ed1153515..07fcb179d15 100644 --- a/app/assets/javascripts/visual_review_toolbar/components/constants.js +++ b/app/assets/javascripts/visual_review_toolbar/components/constants.js @@ -2,10 +2,12 @@ const COLLAPSE_BUTTON = 'gitlab-collapse'; const COMMENT_BOX = 'gitlab-comment'; const COMMENT_BUTTON = 'gitlab-comment-button'; -const FORM = 'gitlab-form-wrapper'; +const FORM = 'gitlab-form'; +const FORM_CONTAINER = 'gitlab-form-wrapper'; const LOGIN = 'gitlab-login'; const LOGOUT = 'gitlab-logout-button'; const NOTE = 'gitlab-validation-note'; +const NOTE_CONTAINER = 'gitlab-note-wrapper'; const REMEMBER_TOKEN = 'gitlab-remember_token'; const REVIEW_CONTAINER = 'gitlab-review-container'; const TOKEN_BOX = 'gitlab-token'; @@ -16,16 +18,18 @@ const BLACK = 'rgba(46, 46, 46, 1)'; const CLEAR = 'rgba(255, 255, 255, 0)'; const MUTED = 'rgba(223, 223, 223, 0.5)'; const RED = 'rgba(219, 59, 33, 1)'; -const WHITE = 'rgba(255, 255, 255, 1)'; +const WHITE = 'rgba(250, 250, 250, 1)'; export { COLLAPSE_BUTTON, COMMENT_BOX, COMMENT_BUTTON, FORM, + FORM_CONTAINER, LOGIN, LOGOUT, NOTE, + NOTE_CONTAINER, REMEMBER_TOKEN, REVIEW_CONTAINER, TOKEN_BOX, diff --git a/app/assets/javascripts/visual_review_toolbar/components/index.js b/app/assets/javascripts/visual_review_toolbar/components/index.js index 43581818152..50b52d7d3a2 100644 --- a/app/assets/javascripts/visual_review_toolbar/components/index.js +++ b/app/assets/javascripts/visual_review_toolbar/components/index.js @@ -1,22 +1,32 @@ import { comment, postComment } from './comment'; -import { COLLAPSE_BUTTON, COMMENT_BUTTON, LOGIN, LOGOUT, REVIEW_CONTAINER } from './constants'; +import { + COLLAPSE_BUTTON, + COMMENT_BUTTON, + FORM_CONTAINER, + LOGIN, + LOGOUT, + REVIEW_CONTAINER, +} from './constants'; import { authorizeUser, login } from './login'; +import { note } from './note'; import { selectContainer } from './utils'; -import { form, logoutUser, toggleForm } from './wrapper'; +import { buttonAndForm, logoutUser, toggleForm } from './wrapper'; import { collapseButton } from './wrapper_icons'; export { authorizeUser, + buttonAndForm, collapseButton, comment, - form, login, logoutUser, + note, postComment, selectContainer, toggleForm, COLLAPSE_BUTTON, COMMENT_BUTTON, + FORM_CONTAINER, LOGIN, LOGOUT, REVIEW_CONTAINER, diff --git a/app/assets/javascripts/visual_review_toolbar/components/login.js b/app/assets/javascripts/visual_review_toolbar/components/login.js index ce713cdc520..0a71299f041 100644 --- a/app/assets/javascripts/visual_review_toolbar/components/login.js +++ b/app/assets/javascripts/visual_review_toolbar/components/login.js @@ -1,5 +1,5 @@ import { LOGIN, REMEMBER_TOKEN, TOKEN_BOX } from './constants'; -import { clearNote, note, postError } from './note'; +import { clearNote, postError } from './note'; import { buttonClearStyles, selectRemember, selectToken } from './utils'; import { addCommentForm } from './wrapper'; @@ -7,7 +7,6 @@ const login = ` <div> <label for="${TOKEN_BOX}" class="gitlab-label">Enter your <a class="gitlab-link" href="https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html">personal access token</a></label> <input class="gitlab-input" type="password" id="${TOKEN_BOX}" name="${TOKEN_BOX}" aria-required="true" autocomplete="current-password"> - ${note} </div> <div class="gitlab-checkbox-wrapper"> <input type="checkbox" id="${REMEMBER_TOKEN}" name="${REMEMBER_TOKEN}" value="remember"> diff --git a/app/assets/javascripts/visual_review_toolbar/components/note.js b/app/assets/javascripts/visual_review_toolbar/components/note.js index dfebf58fd95..0150f640aae 100644 --- a/app/assets/javascripts/visual_review_toolbar/components/note.js +++ b/app/assets/javascripts/visual_review_toolbar/components/note.js @@ -1,14 +1,19 @@ -import { NOTE, RED } from './constants'; -import { selectById, selectNote } from './utils'; +import { NOTE, NOTE_CONTAINER, RED } from './constants'; +import { selectById, selectNote, selectNoteContainer } from './utils'; const note = ` - <p id=${NOTE} class='gitlab-message'></p> + <div id="${NOTE_CONTAINER}" style="visibility: hidden;"> + <p id="${NOTE}" class="gitlab-message"></p> + </div> `; const clearNote = inputId => { const currentNote = selectNote(); + const noteContainer = selectNoteContainer(); + currentNote.innerText = ''; currentNote.style.color = ''; + noteContainer.style.visibility = 'hidden'; if (inputId) { const field = document.getElementById(inputId); @@ -18,10 +23,13 @@ const clearNote = inputId => { const postError = (message, inputId) => { const currentNote = selectNote(); + const noteContainer = selectNoteContainer(); const field = selectById(inputId); field.style.borderColor = RED; currentNote.style.color = RED; currentNote.innerText = message; + noteContainer.style.visibility = 'visible'; + setTimeout(clearNote.bind(null, inputId), 5000); }; export { clearNote, note, postError }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/utils.js b/app/assets/javascripts/visual_review_toolbar/components/utils.js index 7bc2e5a905b..00f4460925d 100644 --- a/app/assets/javascripts/visual_review_toolbar/components/utils.js +++ b/app/assets/javascripts/visual_review_toolbar/components/utils.js @@ -5,7 +5,9 @@ import { COMMENT_BOX, COMMENT_BUTTON, FORM, + FORM_CONTAINER, NOTE, + NOTE_CONTAINER, REMEMBER_TOKEN, REVIEW_CONTAINER, TOKEN_BOX, @@ -24,7 +26,9 @@ const selectCommentBox = () => document.getElementById(COMMENT_BOX); const selectCommentButton = () => document.getElementById(COMMENT_BUTTON); const selectContainer = () => document.getElementById(REVIEW_CONTAINER); const selectForm = () => document.getElementById(FORM); +const selectFormContainer = () => document.getElementById(FORM_CONTAINER); const selectNote = () => document.getElementById(NOTE); +const selectNoteContainer = () => document.getElementById(NOTE_CONTAINER); const selectRemember = () => document.getElementById(REMEMBER_TOKEN); const selectToken = () => document.getElementById(TOKEN_BOX); @@ -36,7 +40,9 @@ export { selectCommentBox, selectCommentButton, selectForm, + selectFormContainer, selectNote, + selectNoteContainer, selectRemember, selectToken, }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/wrapper.js b/app/assets/javascripts/visual_review_toolbar/components/wrapper.js index 233b7ec496c..f2eaf1d7916 100644 --- a/app/assets/javascripts/visual_review_toolbar/components/wrapper.js +++ b/app/assets/javascripts/visual_review_toolbar/components/wrapper.js @@ -1,15 +1,28 @@ import { comment } from './comment'; -import { CLEAR, FORM, WHITE } from './constants'; +import { CLEAR, FORM, FORM_CONTAINER, WHITE } from './constants'; import { login } from './login'; -import { selectCollapseButton, selectContainer, selectForm } from './utils'; +import { clearNote } from './note'; +import { + selectCollapseButton, + selectForm, + selectFormContainer, + selectNoteContainer, +} from './utils'; import { commentIcon, compressIcon } from './wrapper_icons'; const form = content => ` - <form id=${FORM}> + <form id="${FORM}"> ${content} </form> `; +const buttonAndForm = ({ content, toggleButton }) => ` + <div id="${FORM_CONTAINER}" class="gitlab-form-open"> + ${toggleButton} + ${form(content)} + </div> +`; + const addCommentForm = () => { const formWrapper = selectForm(); formWrapper.innerHTML = comment; @@ -31,13 +44,15 @@ function logoutUser() { return; } + clearNote(); addLoginForm(); } function toggleForm() { - const container = selectContainer(); const collapseButton = selectCollapseButton(); const currentForm = selectForm(); + const formContainer = selectFormContainer(); + const noteContainer = selectNoteContainer(); const OPEN = 'open'; const CLOSED = 'closed'; @@ -49,7 +64,7 @@ function toggleForm() { const openButtonClasses = ['gitlab-collapse-closed', 'gitlab-collapse-open']; const closedButtonClasses = [...openButtonClasses].reverse(); - const openContainerClasses = ['gitlab-closed-wrapper', 'gitlab-open-wrapper']; + const openContainerClasses = ['gitlab-wrapper-closed', 'gitlab-wrapper-open']; const closedContainerClasses = [...openContainerClasses].reverse(); const stateVals = { @@ -72,11 +87,16 @@ function toggleForm() { const nextState = collapseButton.classList.contains('gitlab-collapse-open') ? CLOSED : OPEN; const currentVals = stateVals[nextState]; - container.classList.replace(...currentVals.containerClasses); - container.style.backgroundColor = currentVals.backgroundColor; + formContainer.classList.replace(...currentVals.containerClasses); + formContainer.style.backgroundColor = currentVals.backgroundColor; + formContainer.classList.toggle('gitlab-form-open'); currentForm.style.display = currentVals.display; collapseButton.classList.replace(...currentVals.buttonClasses); collapseButton.innerHTML = currentVals.icon; + + if (noteContainer && noteContainer.innerText.length > 0) { + noteContainer.style.display = currentVals.display; + } } -export { addCommentForm, addLoginForm, form, logoutUser, toggleForm }; +export { addCommentForm, addLoginForm, buttonAndForm, logoutUser, toggleForm }; diff --git a/app/assets/javascripts/visual_review_toolbar/index.js b/app/assets/javascripts/visual_review_toolbar/index.js index 941d77e25b4..f94eb88835a 100644 --- a/app/assets/javascripts/visual_review_toolbar/index.js +++ b/app/assets/javascripts/visual_review_toolbar/index.js @@ -1,6 +1,6 @@ import './styles/toolbar.css'; -import { form, selectContainer, REVIEW_CONTAINER } from './components'; +import { buttonAndForm, note, selectContainer, REVIEW_CONTAINER } from './components'; import { debounce, eventLookup, getInitialView, initializeState, updateWindowSize } from './store'; /* @@ -20,12 +20,11 @@ import { debounce, eventLookup, getInitialView, initializeState, updateWindowSiz window.addEventListener('load', () => { initializeState(window, document); - const { content, toggleButton } = getInitialView(window); + const mainContent = buttonAndForm(getInitialView(window)); const container = document.createElement('div'); - container.setAttribute('id', REVIEW_CONTAINER); - container.insertAdjacentHTML('beforeend', toggleButton); - container.insertAdjacentHTML('beforeend', form(content)); + container.insertAdjacentHTML('beforeend', note); + container.insertAdjacentHTML('beforeend', mainContent); document.body.insertBefore(container, document.body.firstChild); diff --git a/app/assets/javascripts/visual_review_toolbar/store/state.js b/app/assets/javascripts/visual_review_toolbar/store/state.js index f5ede6e85b2..22702d524b8 100644 --- a/app/assets/javascripts/visual_review_toolbar/store/state.js +++ b/app/assets/javascripts/visual_review_toolbar/store/state.js @@ -34,7 +34,7 @@ const initializeState = (wind, doc) => { const browser = getBrowserId(userAgent); const scriptEl = doc.getElementById('review-app-toolbar-script'); - const { projectId, mergeRequestId, mrUrl } = scriptEl.dataset; + const { projectId, mergeRequestId, mrUrl, projectPath } = scriptEl.dataset; // This mutates our default state object above. It's weird but it makes the linter happy. Object.assign(state, { @@ -46,6 +46,7 @@ const initializeState = (wind, doc) => { mrUrl, platform, projectId, + projectPath, userAgent, }); }; diff --git a/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css b/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css index 342b3599a44..00a55c0027a 100644 --- a/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css +++ b/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css @@ -6,23 +6,42 @@ pointer-events: none; } -#gitlab-form-wrapper { +#gitlab-comment { + background-color: #fafafa; +} + +#gitlab-form { + display: flex; + flex-direction: column; + width: 100%; + margin-bottom: 0; +} + +#gitlab-note-wrapper { display: flex; flex-direction: column; - width: 100% + background-color: #fafafa; + border-radius: 4px; + margin-bottom: .5rem; + padding: 1rem; +} + +#gitlab-form-wrapper { + overflow: auto; + display: flex; + flex-direction: row-reverse; + border-radius: 4px; } #gitlab-review-container { max-width: 22rem; max-height: 22rem; - overflow: scroll; + overflow: auto; + display: flex; + flex-direction: column; position: fixed; bottom: 1rem; right: 1rem; - display: flex; - flex-direction: row-reverse; - padding: 1rem; - background-color: #fff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; @@ -31,12 +50,12 @@ color: #2e2e2e; } -.gitlab-open-wrapper { +.gitlab-wrapper-open { max-width: 22rem; max-height: 22rem; } -.gitlab-closed-wrapper { +.gitlab-wrapper-closed { max-width: 3.4rem; max-height: 3.4rem; } @@ -47,7 +66,7 @@ } .gitlab-button-secondary { - background: none #fff; + background: none #fafafa; margin: 0 .5rem; border: 1px solid #e3e3e3; } @@ -113,6 +132,11 @@ align-items: baseline; } +.gitlab-form-open { + padding: 1rem; + background-color: #fafafa; +} + .gitlab-label { font-weight: 600; display: inline-block; @@ -126,6 +150,10 @@ background-image: none; } +.gitlab-link:hover { + text-decoration: underline; +} + .gitlab-message { padding: .25rem 0; margin: 0; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue index a9fb40a4949..d4a5fdb4b97 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue @@ -20,7 +20,7 @@ export default { <status-icon :show-disabled-button="true" status="warning" /> <div class="media-body space-children"> <span class="bold"> - {{ s__('mrWidget|There are unresolved discussions. Please resolve these discussions') }} + {{ s__('mrWidget|There are unresolved threads. Please resolve these threads') }} </span> <a v-if="mr.createIssueToResolveDiscussionsPath" 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/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue index 05ad7710a62..eb0f666422f 100644 --- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue +++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue @@ -1,6 +1,6 @@ <script> import '~/commons/bootstrap'; -import { GlTooltipDirective } from '@gitlab/ui'; +import { GlTooltip, GlTooltipDirective } from '@gitlab/ui'; import { sprintf } from '~/locale'; import IssueMilestone from '../../components/issue/issue_milestone.vue'; import IssueAssignees from '../../components/issue/issue_assignees.vue'; @@ -13,6 +13,7 @@ export default { IssueMilestone, IssueAssignees, CiIcon, + GlTooltip, }, directives: { GlTooltip: GlTooltipDirective, @@ -24,11 +25,6 @@ export default { required: false, default: false, }, - greyLinkWhenMerged: { - type: Boolean, - required: false, - default: false, - }, }, computed: { stateTitle() { @@ -41,10 +37,12 @@ export default { }, ); }, - issueableLinkClass() { - return this.greyLinkWhenMerged - ? `sortable-link ${this.state === 'merged' ? ' text-secondary' : ''}` - : 'sortable-link'; + heightStyle() { + return { + minHeight: '32px', + width: '0px', + visibility: 'hidden', + }; }, }, }; @@ -56,20 +54,25 @@ export default { 'issuable-info-container': !canReorder, 'card-body': canReorder, }" - class="item-body d-flex align-items-center p-2 p-lg-3 p-xl-2 pl-xl-3" + class="item-body d-flex align-items-center p-2 p-lg-3 py-xl-2 px-xl-3" > <div class="item-contents d-flex align-items-center flex-wrap flex-grow-1 flex-xl-nowrap"> - <div class="item-title d-flex align-items-center mb-1 mb-xl-0"> - <icon - v-if="hasState" - v-tooltip - :css-classes="iconClass" - :name="iconName" - :size="16" - :title="stateTitle" - :aria-label="state" - data-html="true" - /> + <!-- Title area: Status icon (XL) and title --> + <div class="item-title d-flex align-items-center mb-xl-0"> + <span ref="iconElementXL"> + <icon + v-if="hasState" + ref="iconElementXL" + :css-classes="iconClass" + :name="iconName" + :size="16" + :title="stateTitle" + :aria-label="state" + /> + </span> + <gl-tooltip :target="() => $refs.iconElementXL"> + <span v-html="stateTitle"></span> + </gl-tooltip> <icon v-if="confidential" v-gl-tooltip @@ -79,55 +82,81 @@ export default { class="confidential-icon append-right-4 align-self-baseline align-self-md-auto mt-xl-0" :aria-label="__('Confidential')" /> - <a :href="computedPath" :class="issueableLinkClass">{{ title }}</a> + <a :href="computedPath" class="sortable-link">{{ title }}</a> </div> - <div class="item-meta d-flex flex-wrap mt-xl-0 justify-content-xl-end flex-xl-nowrap"> - <div - class="d-flex align-items-center item-path-id order-md-0 mt-md-0 mt-1 ml-xl-2 mr-xl-auto" - > - <icon - v-if="hasState" - v-tooltip - :css-classes="iconClass" - :name="iconName" - :size="16" - :title="stateTitle" - :aria-label="state" - data-html="true" - class="d-xl-none" - /> - <span v-tooltip :title="itemPath" class="path-id-text d-inline-block">{{ - itemPath - }}</span> - {{ pathIdSeparator }}{{ itemId }} - </div> + + <!-- Info area: meta, path, and assignees --> + <div class="item-info-area d-flex flex-xl-grow-1 flex-shrink-0"> + <!-- Meta area: path and attributes --> + <!-- If there is no room beside the path, meta attributes are put ABOVE it (flex-wrap-reverse). --> + <!-- See design: https://gitlab-org.gitlab.io/gitlab-design/hosted/pedro/%2383-issue-mr-rows-cards-spec-previews/#artboard16 --> <div - class="item-meta-child d-flex align-items-center order-0 flex-wrap mr-md-1 ml-md-auto ml-xl-2 flex-xl-nowrap" + class="item-meta d-flex flex-wrap-reverse justify-content-start justify-content-md-between" > - <span v-if="hasPipeline" class="mr-ci-status pr-2"> - <a :href="pipelineStatus.details_path"> - <ci-icon v-gl-tooltip :status="pipelineStatus" :title="pipelineStatusTooltip" /> - </a> - </span> - <issue-milestone - v-if="hasMilestone" - :milestone="milestone" - class="d-flex align-items-center item-milestone" - /> - <slot name="dueDate"></slot> - <slot name="weight"></slot> + <!-- Path area: status icon (<XL), path, issue # --> + <div + class="item-path-area item-path-id d-flex align-items-center mr-2 mt-2 mt-xl-0 ml-xl-2" + > + <span ref="iconElement"> + <icon + v-if="hasState" + :css-classes="iconClass" + :name="iconName" + :title="stateTitle" + :aria-label="state" + data-html="true" + class="d-xl-none" + /> + </span> + <gl-tooltip :target="() => this.$refs.iconElement"> + <span v-html="stateTitle"></span> + </gl-tooltip> + <span v-gl-tooltip :title="itemPath" class="path-id-text d-inline-block">{{ + itemPath + }}</span> + <span>{{ pathIdSeparator }}{{ itemId }}</span> + </div> + + <!-- Attributes area: CI, epic count, weight, milestone --> + <!-- They have a different order on large screen sizes --> + <div class="item-attributes-area d-flex align-items-center mt-2 mt-xl-0"> + <span v-if="hasPipeline" class="mr-ci-status order-md-last"> + <a :href="pipelineStatus.details_path"> + <ci-icon v-gl-tooltip :status="pipelineStatus" :title="pipelineStatusTooltip" /> + </a> + </span> + + <issue-milestone + v-if="hasMilestone" + :milestone="milestone" + class="d-flex align-items-center item-milestone order-md-first ml-md-0" + /> + + <!-- Flex order for slots is defined in the parent component: e.g. related_issues_block.vue --> + <slot name="dueDate"></slot> + <slot name="weight"></slot> + + <issue-assignees + v-if="hasAssignees" + :assignees="assignees" + class="item-assignees align-items-center align-self-end flex-shrink-0 order-md-2 d-none d-md-flex" + /> + </div> </div> + + <!-- Assignees. On small layouts, these are put here, at the end of the card. --> <issue-assignees - v-if="assignees.length" + v-if="assignees.length !== 0" :assignees="assignees" - class="item-assignees d-inline-flex align-items-center align-self-end ml-auto ml-md-0 mb-md-0 order-2 flex-xl-grow-0 mt-xl-0 mr-xl-1" + class="item-assignees d-flex align-items-center align-self-end flex-shrink-0 d-md-none ml-2" /> </div> </div> + <button v-if="canRemove" ref="removeButton" - v-tooltip + v-gl-tooltip :disabled="removeDisabled" type="button" class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button qa-remove-issue-button mr-xl-0 align-self-xl-center" @@ -137,5 +166,9 @@ export default { > <icon :size="16" class="btn-item-remove-icon" name="close" /> </button> + + <!-- This element serves to set the issue card's height at a minimum of 32 px. --> + <!-- It fixes #59594: when the remove button is missing, issues have inconsistent heights. --> + <span :style="heightStyle"></span> </div> </template> 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/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js index 8e0e4baa75a..3c727cb7b3f 100644 --- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js +++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js @@ -126,6 +126,9 @@ const mixins = { hasTitle() { return this.title.length > 0; }, + hasAssignees() { + return this.assignees.length > 0; + }, hasMilestone() { return !_.isEmpty(this.milestone); }, 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/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss index 7f9cf1266b1..59224d37744 100644 --- a/app/assets/stylesheets/components/related_items_list.scss +++ b/app/assets/stylesheets/components/related_items_list.scss @@ -83,6 +83,20 @@ $item-weight-max-width: 48px; flex-basis: 100%; } + .item-attributes-area { + > * { + margin-left: 8px; + } + + .board-card-info { + margin-right: 0; + } + + @include media-breakpoint-down(sm) { + margin-left: -8px; + } + } + .item-milestone, .item-weight { cursor: help; @@ -101,39 +115,39 @@ $item-weight-max-width: 48px; .item-weight { max-width: $item-weight-max-width; } +} - .item-assignees { - .user-avatar-link { - margin-right: -$gl-padding-4; - - &:nth-of-type(1) { - z-index: 2; - } +.item-assignees { + .user-avatar-link { + margin-right: -$gl-padding-4; - &:nth-of-type(2) { - z-index: 1; - } + &:nth-of-type(1) { + z-index: 2; + } - &:last-child { - margin-right: 0; - } + &:nth-of-type(2) { + z-index: 1; } - .avatar { - height: $gl-padding; - width: $gl-padding; + &:last-child { margin-right: 0; - vertical-align: bottom; } + } - .avatar-counter { - height: $gl-padding; - border: 1px solid transparent; - background-color: $gl-text-color-tertiary; - font-weight: $gl-font-weight-bold; - padding: 0 $gl-padding-4; - line-height: $gl-padding; - } + .avatar { + height: $gl-padding; + width: $gl-padding; + margin-right: 0; + vertical-align: bottom; + } + + .avatar-counter { + height: $gl-padding; + border: 1px solid transparent; + background-color: $gl-text-color-tertiary; + font-weight: $gl-font-weight-bold; + padding: 0 $gl-padding-4; + line-height: $gl-padding; } } @@ -150,12 +164,6 @@ $item-weight-max-width: 48px; .issue-token-state-icon-closed { display: block; } - - @include media-breakpoint-down(sm) { - &:not(.mr-item-path) { - order: 1; - } - } } .btn-item-remove { @@ -179,6 +187,10 @@ $item-weight-max-width: 48px; } @include media-breakpoint-up(sm) { + .item-info-area { + flex-basis: 100%; + } + .sortable-link { max-width: 90%; } @@ -241,7 +253,8 @@ $item-weight-max-width: 48px; .item-title { min-width: 0; width: auto; - flex-basis: unset; + flex-basis: auto; + flex-shrink: 1; font-weight: $gl-font-weight-normal; .issue-token-state-icon-open, @@ -250,6 +263,10 @@ $item-weight-max-width: 48px; margin-right: $gl-padding-8; } } + + .item-info-area { + flex-basis: auto; + } } .item-contents { diff --git a/app/assets/stylesheets/components/toast.scss b/app/assets/stylesheets/components/toast.scss index 33e1c4e5349..acbd909d595 100644 --- a/app/assets/stylesheets/components/toast.scss +++ b/app/assets/stylesheets/components/toast.scss @@ -21,7 +21,7 @@ background-color: rgba($gray-900, $toast-background-opacity); @include media-breakpoint-down(xs) { - .action:first-child { + .action:first-of-type { // Ensures actions buttons are right aligned on mobile margin-left: auto; } @@ -33,7 +33,7 @@ text-transform: none; font-size: $gl-font-size; - &:first-child { + &:first-of-type { padding-right: 0; } } 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/framework/system_messages.scss b/app/assets/stylesheets/framework/system_messages.scss index 6205ccaa52f..5c298d5a588 100644 --- a/app/assets/stylesheets/framework/system_messages.scss +++ b/app/assets/stylesheets/framework/system_messages.scss @@ -98,14 +98,4 @@ top: auto; bottom: auto; } - - .content-wrapper { - .with-system-header & { - margin-top: 0; - } - - .with-system-footer & { - margin-top: 0; - } - } } 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/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index dcbb23684d1..6a0127eb51c 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -594,18 +594,18 @@ padding: 16px 0; small { - color: $gray-darkest; + color: $gray-700; } } .edited-text { - color: $gray-darkest; + color: $gray-700; display: block; margin: 16px 0 0; font-size: 85%; .author-link { - color: $gray-darkest; + color: $gray-700; } } diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 48289c8f381..8359a60ec9f 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -1,4 +1,18 @@ .issues-list { + &.manual-ordering { + background-color: $gray-light; + border-radius: $border-radius-default; + padding: $gl-padding-8; + + .issue { + background-color: $white-light; + margin-bottom: $gl-padding-8; + border-radius: $border-radius-default; + border: 1px solid $gray-100; + box-shadow: 0 1px 2px $issue-boards-card-shadow; + } + } + .issue { padding: 10px 0 10px $gl-padding; position: relative; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 5cacd42bf0d..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%; + } } } @@ -628,7 +657,7 @@ $note-form-margin-left: 72px; .note-headline-meta { .system-note-separator { - color: $gl-text-color-disabled; + color: $gray-700; } .note-timestamp { @@ -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/application_controller.rb b/app/controllers/application_controller.rb index 7321f719deb..75108bf2646 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -516,4 +516,10 @@ class ApplicationController < ActionController::Base def sentry_context Gitlab::Sentry.context(current_user) end + + def allow_gitaly_ref_name_caching + ::Gitlab::GitalyClient.allow_ref_name_caching do + yield + end + end end diff --git a/app/controllers/concerns/boards_responses.rb b/app/controllers/concerns/boards_responses.rb index 7625600e452..9da2f888ead 100644 --- a/app/controllers/concerns/boards_responses.rb +++ b/app/controllers/concerns/boards_responses.rb @@ -3,8 +3,9 @@ module BoardsResponses include Gitlab::Utils::StrongMemoize + # Overridden on EE module def board_params - params.require(:board).permit(:name, :weight, :milestone_id, :assignee_id, label_ids: []) + params.require(:board).permit(:name) end def parent diff --git a/app/controllers/concerns/continue_params.rb b/app/controllers/concerns/continue_params.rb index 54c0510497f..d5830f6648c 100644 --- a/app/controllers/concerns/continue_params.rb +++ b/app/controllers/concerns/continue_params.rb @@ -6,7 +6,7 @@ module ContinueParams def continue_params continue_params = params[:continue] - return unless continue_params + return {} unless continue_params continue_params = continue_params.permit(:to, :notice, :notice_now) continue_params[:to] = safe_redirect_path(continue_params[:to]) diff --git a/app/controllers/concerns/internal_redirect.rb b/app/controllers/concerns/internal_redirect.rb index 6785e6972d0..fa3716502a0 100644 --- a/app/controllers/concerns/internal_redirect.rb +++ b/app/controllers/concerns/internal_redirect.rb @@ -5,8 +5,8 @@ module InternalRedirect def safe_redirect_path(path) return unless path - # Verify that the string starts with a `/` but not a double `/`. - return unless path =~ %r{^/\w.*$} + # Verify that the string starts with a `/` and a known route character. + return unless path =~ %r{^/[-\w].*$} uri = URI(path) # Ignore anything path of the redirect except for the path, querystring and, diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 88a0690938a..21b3949e361 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -42,7 +42,7 @@ module IssuableCollections @issuables = @issuables.page(params[:page]) @issuables = per_page_for_relative_position if params[:sort] == 'relative_position' - @issuable_meta_data = issuable_meta_data(@issuables, collection_type) + @issuable_meta_data = issuable_meta_data(@issuables, collection_type, current_user) @total_pages = issuable_page_count end # rubocop:enable Gitlab/ModuleWithInstanceVariables diff --git a/app/controllers/concerns/issuable_collections_action.rb b/app/controllers/concerns/issuable_collections_action.rb index 18ed4027eac..4ad287c4a13 100644 --- a/app/controllers/concerns/issuable_collections_action.rb +++ b/app/controllers/concerns/issuable_collections_action.rb @@ -11,7 +11,7 @@ module IssuableCollectionsAction .non_archived .page(params[:page]) - @issuable_meta_data = issuable_meta_data(@issues, collection_type) + @issuable_meta_data = issuable_meta_data(@issues, collection_type, current_user) respond_to do |format| format.html @@ -22,7 +22,7 @@ module IssuableCollectionsAction def merge_requests @merge_requests = issuables_collection.page(params[:page]) - @issuable_meta_data = issuable_meta_data(@merge_requests, collection_type) + @issuable_meta_data = issuable_meta_data(@merge_requests, collection_type, current_user) end # rubocop:enable Gitlab/ModuleWithInstanceVariables diff --git a/app/controllers/concerns/multiple_boards_actions.rb b/app/controllers/concerns/multiple_boards_actions.rb new file mode 100644 index 00000000000..95a6800f55c --- /dev/null +++ b/app/controllers/concerns/multiple_boards_actions.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module MultipleBoardsActions + include Gitlab::Utils::StrongMemoize + extend ActiveSupport::Concern + + included do + include BoardsActions + + before_action :redirect_to_recent_board, only: [:index] + before_action :authenticate_user!, only: [:recent] + before_action :authorize_create_board!, only: [:create] + before_action :authorize_admin_board!, only: [:create, :update, :destroy] + end + + def recent + recent_visits = ::Boards::VisitsFinder.new(parent, current_user).latest(4) + recent_boards = recent_visits.map(&:board) + + render json: serialize_as_json(recent_boards) + end + + def create + board = Boards::CreateService.new(parent, current_user, board_params).execute + + respond_to do |format| + format.json do + if board.persisted? + extra_json = { board_path: board_path(board) } + render json: serialize_as_json(board).merge(extra_json) + else + render json: board.errors, status: :unprocessable_entity + end + end + end + end + + def update + service = Boards::UpdateService.new(parent, current_user, board_params) + + respond_to do |format| + format.json do + if service.execute(board) + extra_json = { board_path: board_path(board) } + render json: serialize_as_json(board).merge(extra_json) + else + render json: board.errors, status: :unprocessable_entity + end + end + end + end + + def destroy + service = Boards::DestroyService.new(parent, current_user) + service.execute(board) + + respond_to do |format| + format.json { head :ok } + format.html { redirect_to boards_path, status: :found } + end + end + + private + + def redirect_to_recent_board + return if request.format.json? || !parent.multiple_issue_boards_available? || !latest_visited_board + + redirect_to board_path(latest_visited_board.board) + end + + def latest_visited_board + @latest_visited_board ||= Boards::VisitsFinder.new(parent, current_user).latest + end + + def authorize_create_board! + check_multiple_group_issue_boards_available! if group? + end + + def authorize_admin_board! + return render_404 unless can?(current_user, :admin_board, parent) + end + + def serializer + BoardSerializer.new(current_user: current_user) + end + + def serialize_as_json(resource) + serializer.represent(resource, serializer: 'board', include_full_project_path: board.group_board?) + end +end diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index f96d1821095..0098c4cdf4c 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -203,17 +203,17 @@ module NotesActions # These params are also sent by the client but we need to set these based on # target_type and target_id because we're checking permissions based on that - create_params[:noteable_type] = params[:target_type].classify + create_params[:noteable_type] = noteable.class.name - case params[:target_type] - when 'commit' - create_params[:commit_id] = params[:target_id] - when 'merge_request' - create_params[:noteable_id] = params[:target_id] + case noteable + when Commit + create_params[:commit_id] = noteable.id + when MergeRequest + create_params[:noteable_id] = noteable.id # Notes on MergeRequest can have an extra `commit_id` context create_params[:commit_id] = params.dig(:note, :commit_id) else - create_params[:noteable_id] = params[:target_id] + create_params[:noteable_id] = noteable.id end end end diff --git a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb index 426f224d26b..f47ead2f0da 100644 --- a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb +++ b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb @@ -14,6 +14,10 @@ module RequiresWhitelistedMonitoringClient end def client_ip_whitelisted? + # Always allow developers to access http://localhost:3000/-/metrics for + # debugging purposes + return true if Rails.env.development? && request.local? + ip_whitelist.any? { |e| e.include?(Gitlab::RequestContext.client_ip) } end 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/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index 09cbc052c76..8f6fcb362d2 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -10,6 +10,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController def index @sort = params[:sort] @todos = @todos.page(params[:page]) + @todos = @todos.with_entity_associations return if redirect_out_of_range(@todos) end 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 e936d771502..797833e3f91 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -7,6 +7,10 @@ class GroupsController < Groups::ApplicationController include PreviewMarkdown include RecordUserLastActivity + before_action do + push_frontend_feature_flag(:manual_sorting) + end + respond_to :html prepend_before_action(only: [:show, :issues]) { authenticate_sessionless_user!(:rss) } @@ -197,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/application_controller.rb b/app/controllers/projects/application_controller.rb index 80e4f54bbf4..b1f285f76d7 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -12,6 +12,11 @@ class Projects::ApplicationController < ApplicationController helper_method :repository, :can_collaborate_with_project?, :user_access + rescue_from Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError do |exception| + log_exception(exception) + render_404 + end + private def project @@ -87,10 +92,4 @@ class Projects::ApplicationController < ApplicationController def check_issues_available! return render_404 unless @project.feature_available?(:issues, current_user) end - - def allow_gitaly_ref_name_caching - ::Gitlab::GitalyClient.allow_ref_name_caching do - yield - end - end end diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb index 95897aaf980..14b02993e6e 100644 --- a/app/controllers/projects/boards_controller.rb +++ b/app/controllers/projects/boards_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Projects::BoardsController < Projects::ApplicationController - include BoardsActions + include MultipleBoardsActions include IssuableCollections before_action :check_issues_available! 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/forks_controller.rb b/app/controllers/projects/forks_controller.rb index 7a1700a206a..ac1c4bc7fd3 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -46,18 +46,14 @@ class Projects::ForksController < Projects::ApplicationController @forked_project ||= ::Projects::ForkService.new(project, current_user, namespace: namespace).execute - if @forked_project.saved? && @forked_project.forked? - if @forked_project.import_in_progress? - redirect_to project_import_path(@forked_project, continue: continue_params) - else - if continue_params - redirect_to continue_params[:to], notice: continue_params[:notice] - else - redirect_to project_path(@forked_project), notice: "The project '#{@forked_project.name}' was successfully forked." - end - end - else + if !@forked_project.saved? || !@forked_project.forked? render :error + elsif @forked_project.import_in_progress? + redirect_to project_import_path(@forked_project, continue: continue_params) + elsif continue_params[:to] + redirect_to continue_params[:to], notice: continue_params[:notice] + else + redirect_to project_path(@forked_project), notice: "The project '#{@forked_project.name}' was successfully forked." end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb index afbf9fd7720..da32ab9e2e0 100644 --- a/app/controllers/projects/imports_controller.rb +++ b/app/controllers/projects/imports_controller.rb @@ -23,7 +23,7 @@ class Projects::ImportsController < Projects::ApplicationController def show if @project.import_finished? - if continue_params&.key?(:to) + if continue_params[:to] redirect_to continue_params[:to], notice: continue_params[:notice] else redirect_to project_path(@project), notice: finished_notice @@ -31,11 +31,7 @@ class Projects::ImportsController < Projects::ApplicationController elsif @project.import_failed? redirect_to new_project_import_path(@project) else - if continue_params && continue_params[:notice_now] - flash.now[:notice] = continue_params[:notice_now] - end - - # Render + flash.now[:notice] = continue_params[:notice_now] end end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index b16f3dd9d82..e275b417784 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -10,6 +10,10 @@ class Projects::IssuesController < Projects::ApplicationController include SpammableActions include RecordUserLastActivity + before_action do + push_frontend_feature_flag(:manual_sorting) + end + def issue_except_actions %i[index calendar new create bulk_update import_csv] end @@ -168,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/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index d7c0039b234..02ff6e872c9 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -103,7 +103,7 @@ class Projects::JobsController < Projects::ApplicationController @build.cancel - if continue_params + if continue_params[:to] redirect_to continue_params[:to] else redirect_to builds_project_pipeline_path(@project, @build.pipeline.id) 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/refs_controller.rb b/app/controllers/projects/refs_controller.rb index b3447812ef2..b4ca9074ca9 100644 --- a/app/controllers/projects/refs_controller.rb +++ b/app/controllers/projects/refs_controller.rb @@ -55,6 +55,7 @@ class Projects::RefsController < Projects::ApplicationController format.html { render_404 } format.json do response.headers["More-Logs-Url"] = @more_log_url if summary.more? + response.headers["More-Logs-Offset"] = summary.next_offset if summary.more? render json: @logs end 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/controllers/projects/templates_controller.rb b/app/controllers/projects/templates_controller.rb index 7ceea4e5b96..f987033a26c 100644 --- a/app/controllers/projects/templates_controller.rb +++ b/app/controllers/projects/templates_controller.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true class Projects::TemplatesController < Projects::ApplicationController - before_action :authenticate_user!, :get_template_class + before_action :authenticate_user! + before_action :authorize_can_read_issuable! + before_action :get_template_class def show template = @template_type.find(params[:key], project) @@ -13,9 +15,20 @@ class Projects::TemplatesController < Projects::ApplicationController private + # User must have: + # - `read_merge_request` to see merge request templates, or + # - `read_issue` to see issue templates + # + # Note params[:template_type] has a route constraint to limit it to + # `merge_request` or `issue` + def authorize_can_read_issuable! + action = [:read_, params[:template_type]].join + + authorize_action!(action) + end + def get_template_class template_types = { issue: Gitlab::Template::IssueTemplate, merge_request: Gitlab::Template::MergeRequestTemplate }.with_indifferent_access @template_type = template_types[params[:template_type]] - render json: [], status: :not_found unless @template_type end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 12db493978b..330e2d0f8a5 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -298,7 +298,7 @@ class ProjectsController < Projects::ApplicationController elsif @project.feature_available?(:issues, current_user) @issues = issuables_collection.page(params[:page]) @collection_type = 'Issue' - @issuable_meta_data = issuable_meta_data(@issues, @collection_type) + @issuable_meta_data = issuable_meta_data(@issues, @collection_type, current_user) end render :show diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 07b38371ab9..b2b151bbcf0 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -3,6 +3,7 @@ class RegistrationsController < Devise::RegistrationsController include Recaptcha::Verify include AcceptsPendingInvitations + include RecaptchaExperimentHelper prepend_before_action :check_captcha, only: :create before_action :whitelist_query_limiting, only: [:destroy] @@ -15,13 +16,6 @@ class RegistrationsController < Devise::RegistrationsController end def create - # To avoid duplicate form fields on the login page, the registration form - # names fields using `new_user`, but Devise still wants the params in - # `user`. - if params["new_#{resource_name}"].present? && params[resource_name].blank? - params[resource_name] = params.delete(:"new_#{resource_name}") - end - accept_pending_invitations super do |new_user| @@ -74,19 +68,35 @@ class RegistrationsController < Devise::RegistrationsController end def after_sign_up_path_for(user) - Gitlab::AppLogger.info("User Created: username=#{user.username} email=#{user.email} ip=#{request.remote_ip} confirmed:#{user.confirmed?}") + Gitlab::AppLogger.info(user_created_message(confirmed: user.confirmed?)) user.confirmed? ? stored_location_for(user) || dashboard_projects_path : users_almost_there_path end def after_inactive_sign_up_path_for(resource) - Gitlab::AppLogger.info("User Created: username=#{resource.username} email=#{resource.email} ip=#{request.remote_ip} confirmed:false") + Gitlab::AppLogger.info(user_created_message) users_almost_there_path end private + def user_created_message(confirmed: false) + "User Created: username=#{resource.username} email=#{resource.email} ip=#{request.remote_ip} confirmed:#{confirmed}" + end + + def ensure_correct_params! + # To avoid duplicate form fields on the login page, the registration form + # names fields using `new_user`, but Devise still wants the params in + # `user`. + if params["new_#{resource_name}"].present? && params[resource_name].blank? + params[resource_name] = params.delete(:"new_#{resource_name}") + end + end + def check_captcha - return unless Feature.enabled?(:registrations_recaptcha, default_enabled: true) + ensure_correct_params! + + return unless Feature.enabled?(:registrations_recaptcha, default_enabled: true) # reCAPTCHA on the UI will still display however + return unless show_recaptcha_sign_up? return unless Gitlab::Recaptcha.load_configurations! return if verify_recaptcha diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index a80ab3bcd28..8c674be58c5 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -5,6 +5,8 @@ class SearchController < ApplicationController include SearchHelper include RendersCommits + around_action :allow_gitaly_ref_name_caching + skip_before_action :authenticate_user! requires_cross_project_access if: -> do search_term_present = params[:search].present? || params[:term].present? diff --git a/app/controllers/snippets/notes_controller.rb b/app/controllers/snippets/notes_controller.rb index eee14b0faf4..612897f27e6 100644 --- a/app/controllers/snippets/notes_controller.rb +++ b/app/controllers/snippets/notes_controller.rb @@ -5,8 +5,8 @@ class Snippets::NotesController < ApplicationController include ToggleAwardEmoji skip_before_action :authenticate_user!, only: [:index] - before_action :snippet - before_action :authorize_read_snippet!, only: [:show, :index, :create] + before_action :authorize_read_snippet!, only: [:show, :index] + before_action :authorize_create_note!, only: [:create] private @@ -33,4 +33,8 @@ class Snippets::NotesController < ApplicationController def authorize_read_snippet! return render_404 unless can?(current_user, :read_personal_snippet, snippet) end + + def authorize_create_note! + access_denied! unless can?(current_user, :create_note, noteable) + end end diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb index 8ea5450b4e8..fad036b8df8 100644 --- a/app/controllers/snippets_controller.rb +++ b/app/controllers/snippets_controller.rb @@ -137,7 +137,7 @@ class SnippetsController < ApplicationController def move_temporary_files params[:files].each do |file| - FileMover.new(file, @snippet).execute + FileMover.new(file, from_model: current_user, to_model: @snippet).execute end end end diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index 5d28635232b..94bd18f70d4 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -41,7 +41,11 @@ class UploadsController < ApplicationController when Note can?(current_user, :read_project, model.project) when User - true + # We validate the current user has enough (writing) + # access to itself when a secret is given. + # For instance, user avatars are readable by anyone, + # while temporary, user snippet uploads are not. + !secret? || can?(current_user, :update_user, model) when Appearance true else @@ -56,9 +60,13 @@ class UploadsController < ApplicationController def authorize_create_access! return unless model - # for now we support only personal snippets comments. Only personal_snippet - # is allowed as a model to #create through routing. - authorized = can?(current_user, :create_note, model) + authorized = + case model + when User + can?(current_user, :update_user, model) + else + can?(current_user, :create_note, model) + end render_unauthorized unless authorized end @@ -75,6 +83,10 @@ class UploadsController < ApplicationController User === model || Appearance === model end + def secret? + params[:secret].present? + end + def upload_model_class MODEL_CLASSES[params[:model]] || raise(UnknownUploadModelError) end diff --git a/app/finders/autocomplete/acts_as_taggable_on/tags_finder.rb b/app/finders/autocomplete/acts_as_taggable_on/tags_finder.rb index f38c187799c..78a17312e26 100644 --- a/app/finders/autocomplete/acts_as_taggable_on/tags_finder.rb +++ b/app/finders/autocomplete/acts_as_taggable_on/tags_finder.rb @@ -22,8 +22,7 @@ module Autocomplete end def filter_by_name(tags) - return tags unless search - return tags.none if search.empty? + return tags unless search.present? if search.length >= Gitlab::SQL::Pattern::MIN_CHARS_FOR_PARTIAL_MATCHING tags.named_like(search) diff --git a/app/finders/boards/visits_finder.rb b/app/finders/boards/visits_finder.rb new file mode 100644 index 00000000000..d17a27f72dc --- /dev/null +++ b/app/finders/boards/visits_finder.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Boards + class VisitsFinder + attr_accessor :params, :current_user, :parent + + def initialize(parent, current_user) + @current_user = current_user + @parent = parent + end + + def execute(count = nil) + return unless current_user + + recent_visit_model.latest(current_user, parent, count: count) + end + + alias_method :latest, :execute + + private + + def recent_visit_model + parent.is_a?(Group) ? BoardGroupRecentVisit : BoardProjectRecentVisit + 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/ci/detailed_status_type.rb b/app/graphql/types/ci/detailed_status_type.rb index 2987354b556..5f7d7a934ce 100644 --- a/app/graphql/types/ci/detailed_status_type.rb +++ b/app/graphql/types/ci/detailed_status_type.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true module Types module Ci + # rubocop: disable Graphql/AuthorizeTypes + # This is presented through `PipelineType` that has its own authorization class DetailedStatusType < BaseObject graphql_name 'DetailedStatus' @@ -13,5 +15,6 @@ module Types field :text, GraphQL::STRING_TYPE, null: false field :tooltip, GraphQL::STRING_TYPE, null: false, method: :status_tooltip end + # rubocop: enable Graphql/AuthorizeTypes 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/issue_state_enum.rb b/app/graphql/types/issue_state_enum.rb index 6521407fc9d..70c34fbe491 100644 --- a/app/graphql/types/issue_state_enum.rb +++ b/app/graphql/types/issue_state_enum.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true module Types + # rubocop: disable Graphql/AuthorizeTypes + # This is a BaseEnum through IssuableEnum, so it does not need authorization class IssueStateEnum < IssuableStateEnum graphql_name 'IssueState' description 'State of a GitLab issue' end + # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/label_type.rb b/app/graphql/types/label_type.rb index 50eb1b89c61..3aeda2e7953 100644 --- a/app/graphql/types/label_type.rb +++ b/app/graphql/types/label_type.rb @@ -4,6 +4,8 @@ module Types class LabelType < BaseObject graphql_name 'Label' + authorize :read_label + field :description, GraphQL::STRING_TYPE, null: true markdown_field :description_html, null: true field :title, GraphQL::STRING_TYPE, null: false diff --git a/app/graphql/types/merge_request_state_enum.rb b/app/graphql/types/merge_request_state_enum.rb index 92f52726ab3..37c890a3c8d 100644 --- a/app/graphql/types/merge_request_state_enum.rb +++ b/app/graphql/types/merge_request_state_enum.rb @@ -1,10 +1,13 @@ # frozen_string_literal: true module Types + # rubocop: disable Graphql/AuthorizeTypes + # This is a BaseEnum through IssuableEnum, so it does not need authorization class MergeRequestStateEnum < IssuableStateEnum graphql_name 'MergeRequestState' description 'State of a GitLab merge request' value 'merged' end + # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/metadata_type.rb b/app/graphql/types/metadata_type.rb index 2d8bad0614b..7d7813a7652 100644 --- a/app/graphql/types/metadata_type.rb +++ b/app/graphql/types/metadata_type.rb @@ -4,6 +4,8 @@ module Types class MetadataType < ::Types::BaseObject graphql_name 'Metadata' + authorize :read_instance_metadata + field :version, GraphQL::STRING_TYPE, null: false field :revision, GraphQL::STRING_TYPE, null: false 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/namespace_type.rb b/app/graphql/types/namespace_type.rb index 62feccaa660..f105e9e6e28 100644 --- a/app/graphql/types/namespace_type.rb +++ b/app/graphql/types/namespace_type.rb @@ -4,6 +4,8 @@ module Types class NamespaceType < BaseObject graphql_name 'Namespace' + authorize :read_namespace + field :id, GraphQL::ID_TYPE, null: false field :name, GraphQL::STRING_TYPE, null: false diff --git a/app/graphql/types/notes/diff_position_type.rb b/app/graphql/types/notes/diff_position_type.rb index 104ccb79bbb..ebc24451715 100644 --- a/app/graphql/types/notes/diff_position_type.rb +++ b/app/graphql/types/notes/diff_position_type.rb @@ -2,6 +2,8 @@ module Types module Notes + # rubocop: disable Graphql/AuthorizeTypes + # This is presented through `NoteType` that has its own authorization class DiffPositionType < BaseObject graphql_name 'DiffPosition' @@ -42,5 +44,6 @@ module Types description: "The total height of the image", resolve: -> (position, _args, _ctx) { position.height if position.on_image? } end + # rubocop: enable Graphql/AuthorizeTypes end end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index ac957eafafc..c25688ab043 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -67,14 +67,14 @@ module Types field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::BOOLEAN_TYPE, null: true field :printing_merge_request_link_enabled, GraphQL::BOOLEAN_TYPE, null: true - field :namespace, Types::NamespaceType, null: false + field :namespace, Types::NamespaceType, null: true field :group, Types::GroupType, null: true field :statistics, Types::ProjectStatisticsType, null: true, resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchProjectStatisticsLoader.new(obj.id).find } - field :repository, Types::RepositoryType, null: false + field :repository, Types::RepositoryType, null: true field :merge_requests, Types::MergeRequestType.connection_type, diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 536bdb077ad..53d36b43576 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -22,10 +22,7 @@ module Types field :metadata, Types::MetadataType, null: true, resolver: Resolvers::MetadataResolver, - description: 'Metadata about GitLab' do |*args| - - authorize :read_instance_metadata - end + description: 'Metadata about GitLab' field :echo, GraphQL::STRING_TYPE, null: false, function: Functions::Echo.new end diff --git a/app/graphql/types/task_completion_status.rb b/app/graphql/types/task_completion_status.rb index c289802509d..ac128481ac4 100644 --- a/app/graphql/types/task_completion_status.rb +++ b/app/graphql/types/task_completion_status.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true module Types + # rubocop: disable Graphql/AuthorizeTypes + # This is used in `IssueType` and `MergeRequestType` both of which have their + # own authorization class TaskCompletionStatus < BaseObject graphql_name 'TaskCompletionStatus' description 'Completion status of tasks' @@ -8,4 +11,5 @@ module Types field :count, GraphQL::INT_TYPE, null: false field :completed_count, GraphQL::INT_TYPE, null: false end + # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/tree/blob_type.rb b/app/graphql/types/tree/blob_type.rb index 760781f3612..9497e378dc0 100644 --- a/app/graphql/types/tree/blob_type.rb +++ b/app/graphql/types/tree/blob_type.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true module Types module Tree + # rubocop: disable Graphql/AuthorizeTypes + # This is presented through `Repository` that has its own authorization class BlobType < BaseObject implements Types::Tree::EntryType @@ -12,6 +14,7 @@ module Types field :lfs_oid, GraphQL::STRING_TYPE, null: true, resolve: -> (blob, args, ctx) do Gitlab::Graphql::Loaders::BatchLfsOidLoader.new(blob.repository, blob.id).find end + # rubocop: enable Graphql/AuthorizeTypes end end end diff --git a/app/graphql/types/tree/submodule_type.rb b/app/graphql/types/tree/submodule_type.rb index cea76dbfd2a..8cb1e04f5ba 100644 --- a/app/graphql/types/tree/submodule_type.rb +++ b/app/graphql/types/tree/submodule_type.rb @@ -1,10 +1,13 @@ # frozen_string_literal: true module Types module Tree + # rubocop: disable Graphql/AuthorizeTypes + # This is presented through `Repository` that has its own authorization class SubmoduleType < BaseObject implements Types::Tree::EntryType graphql_name 'Submodule' end + # rubocop: enable Graphql/AuthorizeTypes end end diff --git a/app/graphql/types/tree/tree_entry_type.rb b/app/graphql/types/tree/tree_entry_type.rb index 23ec2ef0ec2..d7faa633706 100644 --- a/app/graphql/types/tree/tree_entry_type.rb +++ b/app/graphql/types/tree/tree_entry_type.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true module Types module Tree + # rubocop: disable Graphql/AuthorizeTypes + # This is presented through `Repository` that has its own authorization class TreeEntryType < BaseObject implements Types::Tree::EntryType @@ -11,5 +13,6 @@ module Types field :web_url, GraphQL::STRING_TYPE, null: true end + # rubocop: enable Graphql/AuthorizeTypes end end diff --git a/app/graphql/types/tree/tree_type.rb b/app/graphql/types/tree/tree_type.rb index 1ee93ed9542..b947713074e 100644 --- a/app/graphql/types/tree/tree_type.rb +++ b/app/graphql/types/tree/tree_type.rb @@ -1,9 +1,16 @@ # frozen_string_literal: true module Types module Tree + # rubocop: disable Graphql/AuthorizeTypes + # This is presented through `Repository` that has its own authorization 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 @@ -13,6 +20,7 @@ module Types field :blobs, Types::Tree::BlobType.connection_type, null: false, resolve: -> (obj, args, ctx) do Gitlab::Graphql::Representation::TreeEntry.decorate(obj.blobs, obj.repository) end + # rubocop: enable Graphql/AuthorizeTypes end end end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 4469118f065..4bf9b708401 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][]' @@ -165,8 +165,6 @@ module ApplicationSettingsHelper :authorized_keys_enabled, :auto_devops_enabled, :auto_devops_domain, - :clientside_sentry_dsn, - :clientside_sentry_enabled, :container_registry_token_expire_delay, :default_artifacts_expire_in, :default_branch_protection, @@ -189,6 +187,8 @@ module ApplicationSettingsHelper :gitaly_timeout_default, :gitaly_timeout_medium, :gitaly_timeout_fast, + :grafana_enabled, + :grafana_url, :gravatar_enabled, :hashed_storage_enabled, :help_page_hide_commercial_content, @@ -235,8 +235,6 @@ module ApplicationSettingsHelper :restricted_visibility_levels, :rsa_key_restriction, :send_user_confirmation_email, - :sentry_dsn, - :sentry_enabled, :session_expire_delay, :shared_runners_enabled, :shared_runners_text, @@ -253,6 +251,7 @@ module ApplicationSettingsHelper :throttle_unauthenticated_enabled, :throttle_unauthenticated_period_in_seconds, :throttle_unauthenticated_requests_per_period, + :time_tracking_limit_to_hours, :two_factor_grace_period, :unique_ips_limit_enabled, :unique_ips_limit_per_user, diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index 076976175a9..31c4b27273b 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module AuthHelper - PROVIDERS_WITH_ICONS = %w(twitter github gitlab bitbucket google_oauth2 facebook azure_oauth2 authentiq).freeze + PROVIDERS_WITH_ICONS = %w(twitter github gitlab bitbucket google_oauth2 facebook azure_oauth2 authentiq salesforce).freeze LDAP_PROVIDER = /\Aldap/.freeze def ldap_enabled? diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index 1640f4fc93f..8ef68018d23 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -14,7 +14,9 @@ module BoardsHelper issue_link_base: build_issue_link_base, root_path: root_path, bulk_update_path: @bulk_issues_path, - default_avatar: image_path(default_avatar) + default_avatar: image_path(default_avatar), + time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s, + recent_boards_endpoint: recent_boards_path } end @@ -86,6 +88,18 @@ module BoardsHelper end def boards_link_text - s_("IssueBoards|Board") + if current_board_parent.multiple_issue_boards_available? + s_("IssueBoards|Boards") + else + s_("IssueBoards|Board") + end + end + + def recent_boards_path + recent_project_boards_path(@project) if current_board_parent.is_a?(Project) + end + + def current_board_json + board.to_json end end diff --git a/app/helpers/branches_helper.rb b/app/helpers/branches_helper.rb index eadf48205fc..c759882d7f8 100644 --- a/app/helpers/branches_helper.rb +++ b/app/helpers/branches_helper.rb @@ -8,12 +8,4 @@ module BranchesHelper def protected_branch?(project, branch) ProtectedBranch.protected?(project, branch.name) end - - def diverging_count_label(count) - if count >= Repository::MAX_DIVERGING_COUNT - "#{Repository::MAX_DIVERGING_COUNT - 1}+" - else - count.to_s - end - end end 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/issuables_helper.rb b/app/helpers/issuables_helper.rb index aa53c649b2a..67685ba4e1d 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -280,7 +280,7 @@ module IssuablesHelper initialTaskStatus: issuable.task_status } - data[:hasClosingMergeRequest] = issuable.merge_requests_count != 0 if issuable.is_a?(Issue) + data[:hasClosingMergeRequest] = issuable.merge_requests_count(current_user) != 0 if issuable.is_a?(Issue) if parent.is_a?(Group) data[:groupPath] = parent.path @@ -430,7 +430,8 @@ module IssuablesHelper editable: issuable.dig(:current_user, :can_edit), currentUser: issuable[:current_user], rootPath: root_path, - fullPath: issuable[:project_full_path] + fullPath: issuable[:project_full_path], + timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours } end diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 59332c0b100..dfadcfc33b2 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -5,6 +5,7 @@ module IssuesHelper classes = ["issue"] classes << "closed" if issue.closed? classes << "today" if issue.today? + classes << "user-can-drag" if @sort == 'relative_position' classes.join(' ') 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/helpers/recaptcha_experiment_helper.rb b/app/helpers/recaptcha_experiment_helper.rb new file mode 100644 index 00000000000..d2eb9ac54f6 --- /dev/null +++ b/app/helpers/recaptcha_experiment_helper.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module RecaptchaExperimentHelper + def show_recaptcha_sign_up? + !!Gitlab::Recaptcha.enabled? + end +end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index dfa34ad7020..f5c4686a3bf 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -169,18 +169,17 @@ module SearchHelper autocomplete: 'off' } + opts[:data]['runner-tags-endpoint'] = tag_list_admin_runners_path + if @project.present? opts[:data]['project-id'] = @project.id - opts[:data]['base-endpoint'] = project_path(@project) opts[:data]['labels-endpoint'] = project_labels_path(@project) opts[:data]['milestones-endpoint'] = project_milestones_path(@project) elsif @group.present? opts[:data]['group-id'] = @group.id - opts[:data]['base-endpoint'] = group_canonical_path(@group) opts[:data]['labels-endpoint'] = group_labels_path(@group) opts[:data]['milestones-endpoint'] = group_milestones_path(@group) else - opts[:data]['base-endpoint'] = root_dashboard_path opts[:data]['labels-endpoint'] = dashboard_labels_path opts[:data]['milestones-endpoint'] = dashboard_milestones_path end diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb index ecb2b2d707b..6ccc1fb2ed1 100644 --- a/app/helpers/snippets_helper.rb +++ b/app/helpers/snippets_helper.rb @@ -1,6 +1,16 @@ # frozen_string_literal: true module SnippetsHelper + def snippets_upload_path(snippet, user) + return unless user + + if snippet&.persisted? + upload_path('personal_snippet', id: snippet.id) + else + upload_path('user', id: user.id) + end + end + def reliable_snippet_path(snippet, opts = nil) if snippet.project_id? project_snippet_path(snippet.project, snippet, opts) diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index 6bd78336ed3..645160077f5 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -170,7 +170,7 @@ module TodosHelper end def todo_group_options - groups = current_user.authorized_groups.map do |group| + groups = current_user.authorized_groups.with_route.map do |group| { id: group.id, text: group.full_name } 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 bbe2d2e8fd4..8e558487c1c 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -23,13 +23,12 @@ 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 + ignore_column :sentry_dsn + ignore_column :clientside_sentry_enabled + ignore_column :clientside_sentry_dsn cache_markdown_field :sign_in_text cache_markdown_field :help_page_text @@ -75,14 +74,6 @@ class ApplicationSetting < ApplicationRecord presence: true, if: :recaptcha_enabled - validates :sentry_dsn, - presence: true, - if: :sentry_enabled - - validates :clientside_sentry_dsn, - presence: true, - if: :clientside_sentry_enabled - validates :akismet_api_key, presence: true, if: :akismet_enabled @@ -264,7 +255,6 @@ class ApplicationSetting < ApplicationRecord encode: true before_validation :ensure_uuid! - before_validation :strip_sentry_values before_save :ensure_runners_registration_token before_save :ensure_health_check_access_token @@ -282,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/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 904d650ef96..df4caed175d 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -82,6 +82,7 @@ module ApplicationSettingImplementation throttle_unauthenticated_enabled: false, throttle_unauthenticated_period_in_seconds: 3600, throttle_unauthenticated_requests_per_period: 3600, + time_tracking_limit_to_hours: false, two_factor_grace_period: 48, unique_ips_limit_enabled: false, unique_ips_limit_per_user: 10, @@ -179,27 +180,6 @@ module ApplicationSettingImplementation super(levels&.map { |level| Gitlab::VisibilityLevel.level_value(level) }) end - def strip_sentry_values - sentry_dsn.strip! if sentry_dsn.present? - clientside_sentry_dsn.strip! if clientside_sentry_dsn.present? - end - - def sentry_enabled - Gitlab.config.sentry.enabled || read_attribute(:sentry_enabled) - end - - def sentry_dsn - Gitlab.config.sentry.dsn || read_attribute(:sentry_dsn) - end - - def clientside_sentry_enabled - Gitlab.config.sentry.enabled || read_attribute(:clientside_sentry_enabled) - end - - def clientside_sentry_dsn - Gitlab.config.sentry.clientside_dsn || read_attribute(:clientside_sentry_dsn) - end - def performance_bar_allowed_group Group.find_by_id(performance_bar_allowed_group_id) 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/broadcast_message.rb b/app/models/broadcast_message.rb index 0fd8dca70b4..da4584228ce 100644 --- a/app/models/broadcast_message.rb +++ b/app/models/broadcast_message.rb @@ -45,7 +45,7 @@ class BroadcastMessage < ApplicationRecord end def self.cache_expires_in - nil + 2.weeks end def active? 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/applications/runner.rb b/app/models/clusters/applications/runner.rb index db7fd8524c2..f0256ff4d41 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ApplicationRecord - VERSION = '0.5.2'.freeze + VERSION = '0.6.0'.freeze self.table_name = 'clusters_applications_runners' 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/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index 5afb193cf86..9296c28776b 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -4,7 +4,6 @@ module Clusters module Platforms class Kubernetes < ApplicationRecord include Gitlab::Kubernetes - include ReactiveCaching include EnumWithNil include AfterCommitQueue @@ -46,8 +45,6 @@ module Clusters validate :prevent_modification, on: :update - after_save :clear_reactive_cache! - alias_attribute :ca_pem, :ca_cert delegate :enabled?, to: :cluster, allow_nil: true @@ -96,27 +93,16 @@ module Clusters end end - # Constructs a list of terminals from the reactive cache - # - # Returns nil if the cache is empty, in which case you should try again a - # short time later - def terminals(environment) - with_reactive_cache do |data| - project = environment.project - - pods = filter_by_project_environment(data[:pods], project.full_path_slug, environment.slug) - terminals = pods.flat_map { |pod| terminals_for_pod(api_url, cluster.kubernetes_namespace_for(project), pod) }.compact - terminals.each { |terminal| add_terminal_auth(terminal, terminal_auth) } - end - end - - # Caches resources in the namespace so other calls don't need to block on - # network access - def calculate_reactive_cache + def calculate_reactive_cache_for(environment) return unless enabled? - # We may want to cache extra things in the future - { pods: read_pods } + { pods: read_pods(environment.deployment_namespace) } + end + + def terminals(environment, data) + pods = filter_by_project_environment(data[:pods], environment.project.full_path_slug, environment.slug) + terminals = pods.flat_map { |pod| terminals_for_pod(api_url, environment.deployment_namespace, pod) }.compact + terminals.each { |terminal| add_terminal_auth(terminal, terminal_auth) } end def kubeclient @@ -133,6 +119,12 @@ module Clusters ca_pem: ca_pem) end + def read_pods(namespace) + kubeclient.get_pods(namespace: namespace).as_json + rescue Kubeclient::ResourceNotFoundError + [] + end + def build_kube_client! raise "Incomplete settings" unless api_url @@ -148,19 +140,6 @@ module Clusters ) end - # Returns a hash of all pods in the namespace - def read_pods - # TODO: The project lookup here should be moved (to environment?), - # which will enable reading pods from the correct namespace for group - # and instance clusters. - # This will be done in https://gitlab.com/gitlab-org/gitlab-ce/issues/61156 - return [] unless cluster.project_type? - - kubeclient.get_pods(namespace: cluster.kubernetes_namespace_for(cluster.first_project)).as_json - rescue Kubeclient::ResourceNotFoundError - [] - end - def kubeclient_ssl_options opts = { verify_ssl: OpenSSL::SSL::VERIFY_PEER } 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/issuable.rb b/app/models/concerns/issuable.rb index 127430cc68f..299e413321d 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -29,7 +29,11 @@ module Issuable # This object is used to gather issuable meta data for displaying # upvotes, downvotes, notes and closing merge requests count for issues and merge requests # lists avoiding n+1 queries and improving performance. - IssuableMeta = Struct.new(:upvotes, :downvotes, :user_notes_count, :merge_requests_count) + IssuableMeta = Struct.new(:upvotes, :downvotes, :user_notes_count, :mrs_count) do + def merge_requests_count(user = nil) + mrs_count + end + end included do cache_markdown_field :title, pipeline: :single_line 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 ee6e830d3ec..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 @@ -84,12 +85,9 @@ class Deployment < ApplicationRecord Commit.truncate_sha(sha) end - def cluster - platform = project.deployment_platform(environment: environment.name) - - if platform.present? && platform.respond_to?(:cluster) - platform.cluster - end + # Deprecated - will be replaced by a persisted cluster_id + def deployment_platform_cluster + environment.deployment_platform&.cluster end def execute_hooks @@ -199,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/environment.rb b/app/models/environment.rb index 1f7e8815c8e..b8ee54c1696 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -2,6 +2,8 @@ class Environment < ApplicationRecord include Gitlab::Utils::StrongMemoize + include ReactiveCaching + # Used to generate random suffixes for the slug LETTERS = ('a'..'z').freeze NUMBERS = ('0'..'9').freeze @@ -17,6 +19,7 @@ class Environment < ApplicationRecord before_validation :generate_slug, if: ->(env) { env.slug.blank? } before_save :set_environment_type + after_save :clear_reactive_cache! validates :name, presence: true, @@ -159,7 +162,21 @@ class Environment < ApplicationRecord end def terminals - deployment_platform.terminals(self) if has_terminals? + with_reactive_cache do |data| + deployment_platform.terminals(self, data) + end + end + + def calculate_reactive_cache + return unless has_terminals? && !project.pending_delete? + + deployment_platform.calculate_reactive_cache_for(self) + end + + def deployment_namespace + strong_memoize(:kubernetes_namespace) do + deployment_platform&.kubernetes_namespace_for(project) + end end def has_metrics? 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/issue.rb b/app/models/issue.rb index 30e29911758..982a94315bd 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -250,8 +250,8 @@ class Issue < ApplicationRecord end # rubocop: enable CodeReuse/ServiceClass - def merge_requests_count - merge_requests_closing_issues.count + def merge_requests_count(user = nil) + ::MergeRequestsClosingIssues.count_for_issue(self.id, user) end def labels_hook_attrs 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/merge_request.rb b/app/models/merge_request.rb index df2dc9c49eb..8391d526d18 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -1030,9 +1030,9 @@ class MergeRequest < ApplicationRecord def mergeable_ci_state? return true unless project.only_allow_merge_if_pipeline_succeeds? - return true unless head_pipeline + return false unless actual_head_pipeline - actual_head_pipeline&.success? || actual_head_pipeline&.skipped? + actual_head_pipeline.success? || actual_head_pipeline.skipped? end def environments_for(current_user) @@ -1353,6 +1353,7 @@ class MergeRequest < ApplicationRecord end # TODO: remove once production database rename completes + # https://gitlab.com/gitlab-org/gitlab-ce/issues/47592 alias_attribute :allow_collaboration, :allow_maintainer_to_push def allow_collaboration diff --git a/app/models/merge_requests_closing_issues.rb b/app/models/merge_requests_closing_issues.rb index 61af50841ee..22cedf57b86 100644 --- a/app/models/merge_requests_closing_issues.rb +++ b/app/models/merge_requests_closing_issues.rb @@ -7,11 +7,38 @@ class MergeRequestsClosingIssues < ApplicationRecord validates :merge_request_id, uniqueness: { scope: :issue_id }, presence: true validates :issue_id, presence: true + scope :with_issues, ->(ids) { where(issue_id: ids) } + scope :with_merge_requests_enabled, -> do + joins(:merge_request) + .joins('INNER JOIN project_features ON merge_requests.target_project_id = project_features.project_id') + .where('project_features.merge_requests_access_level >= :access', access: ProjectFeature::ENABLED) + end + + scope :accessible_by, ->(user) do + joins(:merge_request) + .joins('INNER JOIN project_features ON merge_requests.target_project_id = project_features.project_id') + .where('project_features.merge_requests_access_level >= :access OR EXISTS(:authorizations)', + access: ProjectFeature::ENABLED, + authorizations: user.authorizations_for_projects(min_access_level: Gitlab::Access::REPORTER, related_project_column: "merge_requests.target_project_id") + ) + end + class << self - def count_for_collection(ids) - group(:issue_id) - .where(issue_id: ids) - .pluck('issue_id', 'COUNT(*) as count') + def count_for_collection(ids, current_user) + closing_merge_requests(ids, current_user).group(:issue_id).pluck('issue_id', 'COUNT(*) as count') + end + + def count_for_issue(id, current_user) + closing_merge_requests(id, current_user).count + end + + private + + def closing_merge_requests(ids, current_user) + return with_issues(ids) if current_user&.admin? + return with_issues(ids).with_merge_requests_enabled if current_user.blank? + + with_issues(ids).accessible_by(current_user) end end end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 3c270c7396a..af50293a179 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -35,6 +35,8 @@ class Namespace < ApplicationRecord belongs_to :parent, class_name: "Namespace" has_many :children, class_name: "Namespace", foreign_key: :parent_id has_one :chat_team, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_one :root_storage_statistics, class_name: 'Namespace::RootStorageStatistics' + has_one :aggregation_schedule, class_name: 'Namespace::AggregationSchedule' validates :owner, presence: true, unless: ->(n) { n.type == "Group" } validates :name, @@ -248,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? @@ -289,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 new file mode 100644 index 00000000000..355593597c6 --- /dev/null +++ b/app/models/namespace/aggregation_schedule.rb @@ -0,0 +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 new file mode 100644 index 00000000000..56c430013ee --- /dev/null +++ b/app/models/namespace/root_storage_statistics.rb @@ -0,0 +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/pages_domain.rb b/app/models/pages_domain.rb index 07195c0bfd3..d6d879c6d89 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -3,6 +3,7 @@ class PagesDomain < ApplicationRecord VERIFICATION_KEY = 'gitlab-pages-verification-code'.freeze VERIFICATION_THRESHOLD = 3.days.freeze + SSL_RENEWAL_THRESHOLD = 30.days.freeze enum certificate_source: { user_provided: 0, gitlab_provided: 1 }, _prefix: :certificate @@ -41,6 +42,15 @@ class PagesDomain < ApplicationRecord where(verified_at.eq(nil).or(enabled_until.eq(nil).or(enabled_until.lt(threshold)))) end + scope :need_auto_ssl_renewal, -> do + expiring = where(certificate_valid_not_after: nil).or( + where(arel_table[:certificate_valid_not_after].lt(SSL_RENEWAL_THRESHOLD.from_now))) + + user_provided_or_expiring = certificate_user_provided.or(expiring) + + where(auto_ssl_enabled: true).merge(user_provided_or_expiring) + end + scope :for_removal, -> { where("remove_at < ?", Time.now) } def verified? 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 351d08eaf63..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 @@ -1949,9 +1943,8 @@ class Project < ApplicationRecord end end - # Overridden on EE module def multiple_issue_boards_available? - false + true end def full_path_before_last_save 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..d087a5a7bbd 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -6,7 +6,6 @@ class Repository REF_MERGE_REQUEST = 'merge-requests'.freeze REF_KEEP_AROUND = 'keep-around'.freeze REF_ENVIRONMENTS = 'environments'.freeze - MAX_DIVERGING_COUNT = 1000 RESERVED_REFS_NAMES = %W[ heads @@ -282,46 +281,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/todo.rb b/app/models/todo.rb index f1fc5e599eb..240c91da5b6 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -60,7 +60,7 @@ class Todo < ApplicationRecord scope :for_type, -> (type) { where(target_type: type) } scope :for_target, -> (id) { where(target_id: id) } scope :for_commit, -> (id) { where(commit_id: id) } - scope :with_api_entity_associations, -> { preload(:target, :author, :note, group: :route, project: [:route, { namespace: :route }]) } + scope :with_entity_associations, -> { preload(:target, :author, :note, group: :route, project: [:route, { namespace: :route }]) } scope :joins_issue_and_assignees, -> { left_joins(issue: :assignees) } state_machine :state, initial: :pending do 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/policies/project_policy.rb b/app/policies/project_policy.rb index 08bfe5d14ee..3c9ffbb2065 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -196,6 +196,7 @@ class ProjectPolicy < BasePolicy rule { guest & can?(:read_container_image) }.enable :build_read_container_image rule { can?(:reporter_access) }.policy do + enable :admin_board enable :download_code enable :read_statistics enable :download_wiki_code @@ -240,6 +241,7 @@ class ProjectPolicy < BasePolicy rule { can?(:developer_access) & can?(:create_issue) }.enable :import_issues rule { can?(:developer_access) }.policy do + enable :admin_board enable :admin_merge_request enable :admin_milestone enable :update_merge_request @@ -266,6 +268,7 @@ class ProjectPolicy < BasePolicy end rule { can?(:maintainer_access) }.policy do + enable :admin_board enable :push_to_delete_protected_branch enable :update_project_snippet enable :update_environment diff --git a/app/policies/repository_policy.rb b/app/policies/repository_policy.rb new file mode 100644 index 00000000000..32340749858 --- /dev/null +++ b/app/policies/repository_policy.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class RepositoryPolicy < BasePolicy + delegate { @subject.project } +end 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/board_simple_entity.rb b/app/serializers/board_simple_entity.rb index f297d993e27..029d3808e75 100644 --- a/app/serializers/board_simple_entity.rb +++ b/app/serializers/board_simple_entity.rb @@ -2,4 +2,5 @@ class BoardSimpleEntity < Grape::Entity expose :id + expose :name 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/boards/create_service.rb b/app/services/boards/create_service.rb index 1b796cef3e2..dd9358913fd 100644 --- a/app/services/boards/create_service.rb +++ b/app/services/boards/create_service.rb @@ -9,7 +9,7 @@ module Boards private def can_create_board? - parent.boards.empty? + parent.boards.empty? || parent.multiple_issue_boards_available? end def create_board! diff --git a/app/services/boards/destroy_service.rb b/app/services/boards/destroy_service.rb new file mode 100644 index 00000000000..ea0c1394aa3 --- /dev/null +++ b/app/services/boards/destroy_service.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Boards + class DestroyService < Boards::BaseService + def execute(board) + return false if parent.boards.size == 1 + + board.destroy + end + end +end diff --git a/app/services/boards/update_service.rb b/app/services/boards/update_service.rb new file mode 100644 index 00000000000..88aced01ccd --- /dev/null +++ b/app/services/boards/update_service.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Boards + class UpdateService < Boards::BaseService + def execute(board) + board.update(params) + end + end +end diff --git a/app/services/boards/visits/latest_service.rb b/app/services/boards/visits/latest_service.rb deleted file mode 100644 index d13e25b4f12..00000000000 --- a/app/services/boards/visits/latest_service.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Boards - module Visits - class LatestService < Boards::BaseService - def execute - return unless current_user - - recent_visit_model.latest(current_user, parent, count: params[:count]) - end - - private - - def recent_visit_model - parent.is_a?(Group) ? BoardGroupRecentVisit : BoardProjectRecentVisit - end - end - end -end 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..a3404caf2d7 --- /dev/null +++ b/app/services/branches/diverging_commit_counts_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Branches + class DivergingCommitCountsService + def initialize(repository) + @repository = repository + @cache = Gitlab::RepositoryCache.new(repository) + end + + def call(branch) + diverging_commit_counts(branch) + end + + private + + attr_reader :repository, :cache + + delegate :raw_repository, to: :repository + + def diverging_commit_counts(branch) + @root_ref_hash ||= raw_repository.commit(repository.root_ref).id + cache.fetch(:"diverging_commit_counts_#{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/pages_domains/obtain_lets_encrypt_certificate_service.rb b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb index 3413a9e4612..58f795e639e 100644 --- a/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb +++ b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb @@ -2,6 +2,14 @@ module PagesDomains class ObtainLetsEncryptCertificateService + # time for processing validation requests for acme challenges + # 5-15 seconds is usually enough + CHALLENGE_PROCESSING_DELAY = 1.minute.freeze + + # time LetsEncrypt ACME server needs to generate the certificate + # no particular SLA, usually takes 10-15 seconds + CERTIFICATE_PROCESSING_DELAY = 1.minute.freeze + attr_reader :pages_domain def initialize(pages_domain) @@ -14,6 +22,7 @@ module PagesDomains unless acme_order ::PagesDomains::CreateAcmeOrderService.new(pages_domain).execute + PagesDomainSslRenewalWorker.perform_in(CHALLENGE_PROCESSING_DELAY, pages_domain.id) return end @@ -23,6 +32,7 @@ module PagesDomains case api_order.status when 'ready' api_order.request_certificate(private_key: acme_order.private_key, domain: pages_domain.domain) + PagesDomainSslRenewalWorker.perform_in(CERTIFICATE_PROCESSING_DELAY, pages_domain.id) when 'valid' save_certificate(acme_order.private_key, api_order) acme_order.destroy! diff --git a/app/services/projects/propagate_service_template.rb b/app/services/projects/propagate_service_template.rb index a2f36d2bd1b..a25c985585b 100644 --- a/app/services/projects/propagate_service_template.rb +++ b/app/services/projects/propagate_service_template.rb @@ -24,7 +24,7 @@ module Projects def propagate_projects_with_template loop do - batch = project_ids_batch + batch = Project.uncached { project_ids_batch } bulk_create_from_template(batch) unless batch.empty? 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..237ddbcf2c2 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -249,7 +249,7 @@ module SystemNoteService end def resolve_all_discussions(merge_request, project, author) - body = "resolved all discussions" + body = "resolved all threads" create_note(NoteSummary.new(merge_request, project, author, body, action: 'discussion')) end @@ -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/uploaders/file_mover.rb b/app/uploaders/file_mover.rb index 236b7ed2b3d..12be1e2bb22 100644 --- a/app/uploaders/file_mover.rb +++ b/app/uploaders/file_mover.rb @@ -1,22 +1,29 @@ # frozen_string_literal: true class FileMover - attr_reader :secret, :file_name, :model, :update_field + include Gitlab::Utils::StrongMemoize - def initialize(file_path, model, update_field = :description) + attr_reader :secret, :file_name, :from_model, :to_model, :update_field + + def initialize(file_path, update_field = :description, from_model:, to_model:) @secret = File.split(File.dirname(file_path)).last @file_name = File.basename(file_path) - @model = model + @from_model = from_model + @to_model = to_model @update_field = update_field end def execute + temp_file_uploader.retrieve_from_store!(file_name) + return unless valid? + uploader.retrieve_from_store!(file_name) + move if update_markdown - uploader.record_upload + update_upload_model uploader.schedule_background_upload end end @@ -24,52 +31,77 @@ class FileMover private def valid? - Pathname.new(temp_file_path).realpath.to_path.start_with?( - (Pathname(temp_file_uploader.root) + temp_file_uploader.base_dir).to_path - ) + if temp_file_uploader.file_storage? + Pathname.new(temp_file_path).realpath.to_path.start_with?( + (Pathname(temp_file_uploader.root) + temp_file_uploader.base_dir).to_path + ) + else + temp_file_uploader.exists? + end end def move - FileUtils.mkdir_p(File.dirname(file_path)) - FileUtils.move(temp_file_path, file_path) + if temp_file_uploader.file_storage? + FileUtils.mkdir_p(File.dirname(file_path)) + FileUtils.move(temp_file_path, file_path) + else + uploader.copy_file(temp_file_uploader.file) + temp_file_uploader.upload.destroy! + end end def update_markdown - updated_text = model.read_attribute(update_field) - .gsub(temp_file_uploader.markdown_link, uploader.markdown_link) - model.update_attribute(update_field, updated_text) + updated_text = to_model.read_attribute(update_field) + .gsub(temp_file_uploader.markdown_link, uploader.markdown_link) + to_model.update_attribute(update_field, updated_text) rescue revert false end - def temp_file_path - return @temp_file_path if @temp_file_path + def update_upload_model + return unless upload = temp_file_uploader.upload + return if upload.destroyed? - temp_file_uploader.retrieve_from_store!(file_name) + upload.update!(model: to_model) + end - @temp_file_path = temp_file_uploader.file.path + def temp_file_path + strong_memoize(:temp_file_path) do + temp_file_uploader.file.path + end end def file_path - return @file_path if @file_path - - uploader.retrieve_from_store!(file_name) - - @file_path = uploader.file.path + strong_memoize(:file_path) do + uploader.file.path + end end def uploader - @uploader ||= PersonalFileUploader.new(model, secret: secret) + @uploader ||= + begin + uploader = PersonalFileUploader.new(to_model, secret: secret) + + # Enforcing a REMOTE object storage given FileUploader#retrieve_from_store! won't do it + # (there's no upload at the target yet). + if uploader.class.object_store_enabled? + uploader.object_store = ::ObjectStorage::Store::REMOTE + end + + uploader + end end def temp_file_uploader - @temp_file_uploader ||= PersonalFileUploader.new(nil, secret: secret) + @temp_file_uploader ||= PersonalFileUploader.new(from_model, secret: secret) end def revert - Rails.logger.warn("Markdown not updated, file move reverted for #{model}") + Rails.logger.warn("Markdown not updated, file move reverted for #{to_model}") - FileUtils.move(file_path, temp_file_path) + if temp_file_uploader.file_storage? + FileUtils.move(file_path, temp_file_path) + end end end diff --git a/app/validators/color_validator.rb b/app/validators/color_validator.rb index 1932d042e83..974dfbbf394 100644 --- a/app/validators/color_validator.rb +++ b/app/validators/color_validator.rb @@ -12,7 +12,7 @@ # end # class ColorValidator < ActiveModel::EachValidator - PATTERN = /\A\#[0-9A-Fa-f]{3}{1,2}+\Z/.freeze + PATTERN = /\A\#(?:[0-9A-Fa-f]{3}){1,2}\Z/.freeze def validate_each(record, attribute, value) unless value =~ PATTERN diff --git a/app/views/admin/application_settings/_grafana.html.haml b/app/views/admin/application_settings/_grafana.html.haml new file mode 100644 index 00000000000..b6e02bde895 --- /dev/null +++ b/app/views/admin/application_settings/_grafana.html.haml @@ -0,0 +1,17 @@ += form_for @application_setting, url: admin_application_settings_path(anchor: 'js-grafana-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + %p + = _("Add a Grafana button in the admin sidebar, monitoring section, to access a variety of statistics on the health and performance of GitLab.") + = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/grafana_configuration.md') + .form-group + .form-check + = f.check_box :grafana_enabled, class: 'form-check-input' + = f.label :grafana_enabled, class: 'form-check-label' do + = _('Enable access to Grafana') + .form-group + = f.label :grafana_url, _('Grafana URL'), class: 'label-bold' + = f.text_field :grafana_url, class: 'form-control', placeholder: '/-/grafana' + + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/_localization.html.haml b/app/views/admin/application_settings/_localization.html.haml index bb4d1fa1241..e01c123d1db 100644 --- a/app/views/admin/application_settings/_localization.html.haml +++ b/app/views/admin/application_settings/_localization.html.haml @@ -8,4 +8,11 @@ .form-text.text-muted = _('Default first day of the week in calendars and date pickers.') + .form-group + = f.label :time_tracking, _('Time tracking'), class: 'label-bold' + .form-check + = f.check_box :time_tracking_limit_to_hours, class: 'form-check-input' + = f.label :time_tracking_limit_to_hours, class: 'form-check-label' do + = _('Limit display of time tracking units to hours.') + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/_logging.html.haml b/app/views/admin/application_settings/_logging.html.haml deleted file mode 100644 index d57066bba01..00000000000 --- a/app/views/admin/application_settings/_logging.html.haml +++ /dev/null @@ -1,38 +0,0 @@ -= form_for @application_setting, url: reporting_admin_application_settings_path(anchor: 'js-logging-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting) - - %p - %strong - NOTE: - These settings will be removed from the UI in a GitLab 12.0 release and made available within gitlab.yml. - In addition, you will be able to define a Sentry Environment to differentiate between multiple deployments. For example, development, staging, and production. - - %fieldset - .form-group - .form-check - = f.check_box :sentry_enabled, class: 'form-check-input' - = f.label :sentry_enabled, class: 'form-check-label' do - Enable Sentry - .form-text.text-muted - %p This setting requires a restart to take effect. - Sentry is an error reporting and logging tool which is currently not shipped with GitLab, get it here: - %a{ href: 'https://getsentry.com', target: '_blank', rel: 'noopener noreferrer' } https://getsentry.com - - .form-group - = f.label :sentry_dsn, 'Sentry DSN', class: 'label-bold' - = f.text_field :sentry_dsn, class: 'form-control' - - .form-group - .form-check - = f.check_box :clientside_sentry_enabled, class: 'form-check-input' - = f.label :clientside_sentry_enabled, class: 'form-check-label' do - Enable Clientside Sentry - .form-text.text-muted - Sentry can also be used for reporting and logging clientside exceptions. - %a{ href: 'https://sentry.io/for/javascript/', target: '_blank', rel: 'noopener noreferrer' } https://sentry.io/for/javascript/ - - .form-group - = f.label :clientside_sentry_dsn, 'Clientside Sentry DSN', class: 'label-bold' - = f.text_field :clientside_sentry_dsn, class: 'form-control' - - = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_spam.html.haml b/app/views/admin/application_settings/_spam.html.haml index a34fc15acb1..d24e46b2815 100644 --- a/app/views/admin/application_settings/_spam.html.haml +++ b/app/views/admin/application_settings/_spam.html.haml @@ -7,7 +7,10 @@ = f.check_box :recaptcha_enabled, class: 'form-check-input' = f.label :recaptcha_enabled, class: 'form-check-label' do Enable reCAPTCHA - %span.form-text.text-muted#recaptcha_help_block Helps prevent bots from creating accounts + - recaptcha_v2_link_url = 'https://developers.google.com/recaptcha/docs/versions' + - recaptcha_v2_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: recaptcha_v2_link_url } + %span.form-text.text-muted#recaptcha_help_block + = _('Helps prevent bots from creating accounts. We currently only support %{recaptcha_v2_link_start}reCAPTCHA v2%{recaptcha_v2_link_end}').html_safe % { recaptcha_v2_link_start: recaptcha_v2_link_start, recaptcha_v2_link_end: '</a>'.html_safe } .form-group = f.label :recaptcha_site_key, 'reCAPTCHA Site Key', class: 'label-bold' diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml index 01d61beaf53..55a48da8342 100644 --- a/app/views/admin/application_settings/metrics_and_profiling.html.haml +++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml @@ -24,6 +24,17 @@ .settings-content = render 'prometheus' +%section.settings.as-grafana.no-animate#js-grafana-settings{ class: ('expanded' if expanded_by_default?) } + .settings-header + %h4 + = _('Metrics - Grafana') + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded_by_default? ? _('Collapse') : _('Expand') + %p + = _('Enable and configure Grafana.') + .settings-content + = render 'grafana' + %section.settings.qa-performance-bar-settings.as-performance-bar.no-animate#js-performance-bar-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4 diff --git a/app/views/admin/application_settings/reporting.html.haml b/app/views/admin/application_settings/reporting.html.haml index 1c2d9ccdb2d..46e3d1c4570 100644 --- a/app/views/admin/application_settings/reporting.html.haml +++ b/app/views/admin/application_settings/reporting.html.haml @@ -23,14 +23,3 @@ = _('Set notification email for abuse reports.') .settings-content = render 'abuse' - -%section.settings.as-logging.no-animate#js-logging-settings{ class: ('expanded' if expanded_by_default?) } - .settings-header - %h4 - = _('Error Reporting and Logging') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } - = expanded_by_default? ? _('Collapse') : _('Expand') - %p - = _('Enable Sentry for error reporting and logging.') - .settings-content - = render 'logging' diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index 2e23b748edb..5129f5d193b 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -58,7 +58,7 @@ .scroll-container %ul.tokens-container.list-unstyled %li.input-token - %input.form-control.filtered-search{ { id: 'filtered-search-runners', placeholder: _('Search or filter results...') } } + %input.form-control.filtered-search{ search_filter_input_options('runners') } #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { action: 'submit' } } 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/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index eae3ee6339f..034273558bb 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -33,7 +33,7 @@ = accept_terms_label.html_safe = render_if_exists 'devise/shared/email_opted_in', f: f %div - - if Gitlab::Recaptcha.enabled? + - if show_recaptcha_sign_up? = recaptcha_tags .submit-container = f.submit _("Register"), class: "btn-register btn qa-new-user-register-button" diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml index 10187129a33..9659d416a38 100644 --- a/app/views/discussions/_discussion.html.haml +++ b/app/views/discussions/_discussion.html.haml @@ -13,12 +13,12 @@ = icon("chevron-up") - else = icon("chevron-down") - = _('Toggle discussion') + = _('Toggle thread') = link_to_member(@project, discussion.author, avatar: false) .inline.discussion-headline-light = discussion.author.to_reference - started a discussion + started a thread - url = discussion_path(discussion) - if discussion.for_commit? && @noteable != discussion.noteable diff --git a/app/views/discussions/_new_issue_for_discussion.html.haml b/app/views/discussions/_new_issue_for_discussion.html.haml index 2bfe118c608..49d5378d62e 100644 --- a/app/views/discussions/_new_issue_for_discussion.html.haml +++ b/app/views/discussions/_new_issue_for_discussion.html.haml @@ -4,7 +4,7 @@ .btn-group{ role: "group", "v-if" => "showButton" } = link_to custom_icon('icon_mr_issue'), new_project_issue_path(@project, merge_request_to_resolve_discussions_of: merge_request.iid, discussion_to_resolve: discussion.id), - title: 'Resolve this discussion in a new issue', - aria: { label: 'Resolve this discussion in a new issue' }, + title: 'Resolve this thread in a new issue', + aria: { label: 'Resolve this thread in a new issue' }, data: { container: 'body' }, class: 'new-issue-for-discussion btn btn-default discussion-create-issue-btn has-tooltip' 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/_head.html.haml b/app/views/layouts/_head.html.haml index 7535aee83a3..20b844f9fd8 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -46,7 +46,7 @@ = yield :library_javascripts = javascript_include_tag locale_path unless I18n.locale == :en - = webpack_bundle_tag "raven" if Gitlab::CurrentSettings.clientside_sentry_enabled + = webpack_bundle_tag "raven" if Gitlab.config.sentry.enabled - if content_for?(:page_specific_javascripts) = yield :page_specific_javascripts diff --git a/app/views/layouts/fullscreen.html.haml b/app/views/layouts/fullscreen.html.haml index e29f646ed4f..fa04b5be9f2 100644 --- a/app/views/layouts/fullscreen.html.haml +++ b/app/views/layouts/fullscreen.html.haml @@ -10,5 +10,5 @@ = render "layouts/broadcast" = yield :flash_message = render "layouts/flash" - .content-wrapper{ id: "content-body", class: "d-flex flex-column align-items-stretch" } + .content-wrapper{ id: "content-body", class: "d-flex flex-column align-items-stretch mt-0" } = yield diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index 83fe871285a..87133c7ba22 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -81,6 +81,11 @@ = link_to admin_requests_profiles_path, title: _('Requests Profiles') do %span = _('Requests Profiles') + - if Gitlab::CurrentSettings.current_application_settings.grafana_enabled? + = nav_link do + = link_to Gitlab::CurrentSettings.current_application_settings.grafana_url, target: '_blank', title: _('Metrics Dashboard') do + %span + = _('Metrics Dashboard') = render_if_exists 'layouts/nav/ee/admin/new_monitoring_sidebar' = nav_link(controller: :broadcast_messages) do 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/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml index 5f986c81ff4..841b2a5e79c 100644 --- a/app/views/layouts/snippets.html.haml +++ b/app/views/layouts/snippets.html.haml @@ -1,9 +1,10 @@ - header_title _("Snippets"), snippets_path +- snippets_upload_path = snippets_upload_path(@snippet, current_user) - content_for :page_specific_javascripts do - - if @snippet && current_user + - if snippets_upload_path -# haml-lint:disable InlineJavaScript :javascript - window.uploads_path = "#{upload_path('personal_snippet', id: @snippet.id)}"; + window.uploads_path = "#{snippets_upload_path}"; = render template: "layouts/application" 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/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 4c18398e3dc..65ef9690062 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -1,5 +1,5 @@ -- breadcrumb_title "Access Tokens" -- page_title "Personal Access Tokens" +- breadcrumb_title s_('AccessTokens|Access Tokens') +- page_title s_('AccessTokens|Personal Access Tokens') - @content_class = "limit-container-width" unless fluid_layout .row.prepend-top-default @@ -7,10 +7,10 @@ %h4.prepend-top-0 = page_title %p - You can generate a personal access token for each application you use that needs access to the GitLab API. + = s_('AccessTokens|You can generate a personal access token for each application you use that needs access to the GitLab API.') %p - You can also use personal access tokens to authenticate against Git over HTTP. - They are the only accepted password when you have Two-Factor Authentication (2FA) enabled. + = s_('AccessTokens|You can also use personal access tokens to authenticate against Git over HTTP.') + = s_('AccessTokens|They are the only accepted password when you have Two-Factor Authentication (2FA) enabled.') .col-lg-8 - if @new_personal_access_token @@ -24,35 +24,33 @@ .row.prepend-top-default .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 - Feed token + = s_('AccessTokens|Feed token') %p - Your feed token is used to authenticate you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar, and is included in those feed URLs. + = s_('AccessTokens|Your feed token is used to authenticate you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar, and is included in those feed URLs.') %p - It cannot be used to access any other data. + = s_('AccessTokens|It cannot be used to access any other data.') .col-lg-8.feed-token-reset - = label_tag :feed_token, 'Feed token', class: "label-bold" + = label_tag :feed_token, s_('AccessTokens|Feed token'), class: "label-bold" = text_field_tag :feed_token, current_user.feed_token, class: 'form-control', readonly: true, onclick: 'this.select()' %p.form-text.text-muted - Keep this token secret. Anyone who gets ahold of it can read activity and issue RSS feeds or your calendar feed as if they were you. - You should - = link_to 'reset it', [:reset, :feed_token, :profile], method: :put, data: { confirm: 'Are you sure? Any RSS or calendar URLs currently in use will stop working.' } - if that ever happens. + - reset_link = link_to s_('AccessTokens|reset it'), [:reset, :feed_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working.') } + - reset_message = s_('AccessTokens|Keep this token secret. Anyone who gets ahold of it can read activity and issue RSS feeds or your calendar feed as if they were you. You should %{link_reset_it} if that ever happens.') % { link_reset_it: reset_link } + = reset_message.html_safe - if incoming_email_token_enabled? %hr .row.prepend-top-default .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 - Incoming email token + = s_('AccessTokens|Incoming email token') %p - Your incoming email token is used to authenticate you when you create a new issue by email, and is included in your personal project-specific email addresses. + = s_('AccessTokens|Your incoming email token is used to authenticate you when you create a new issue by email, and is included in your personal project-specific email addresses.') %p - It cannot be used to access any other data. + = s_('AccessTokens|It cannot be used to access any other data.') .col-lg-8.incoming-email-token-reset - = label_tag :incoming_email_token, 'Incoming email token', class: "label-bold" + = label_tag :incoming_email_token, s_('AccessTokens|Incoming email token'), class: "label-bold" = text_field_tag :incoming_email_token, current_user.incoming_email_token, class: 'form-control', readonly: true, onclick: 'this.select()' %p.form-text.text-muted - Keep this token secret. Anyone who gets ahold of it can create issues as if they were you. - You should - = link_to 'reset it', [:reset, :incoming_email_token, :profile], method: :put, data: { confirm: 'Are you sure? Any issue email addresses currently in use will stop working.' } - if that ever happens. + - reset_link = link_to s_('AccessTokens|reset it'), [:reset, :incoming_email_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any issue email addresses currently in use will stop working.') } + - reset_message = s_('AccessTokens|Keep this token secret. Anyone who gets ahold of it can create issues as if they were you. You should %{link_reset_it} if that ever happens.') % { link_reset_it: reset_link } + = reset_message.html_safe 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/issues/_issues.html.haml b/app/views/projects/issues/_issues.html.haml index 6f7713124ac..7d539c9d749 100644 --- a/app/views/projects/issues/_issues.html.haml +++ b/app/views/projects/issues/_issues.html.haml @@ -1,6 +1,6 @@ - empty_state_path = local_assigns.fetch(:empty_state_path, 'shared/empty_states/issues') -%ul.content-list.issues-list.issuable-list +%ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position') } = render partial: "projects/issues/issue", collection: @issues - if @issues.blank? = render empty_state_path diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index d7e16dbd40c..1cfe302fdc7 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -46,11 +46,11 @@ = render_if_exists 'projects/new_ci_cd_only_project_tab', active_tab: active_tab .tab-content.gitlab-tab-content - .tab-pane{ id: 'blank-project-pane', class: active_when(active_tab == 'blank'), role: 'tabpanel' } + .tab-pane.js-toggle-container{ id: 'blank-project-pane', class: active_when(active_tab == 'blank'), role: 'tabpanel' } = form_for @project, html: { class: 'new_project' } do |f| = render 'new_project_fields', f: f, project_name_id: "blank-project-name" - #create-from-template-pane.tab-pane.px-0.pb-0{ class: active_when(active_tab == 'template'), role: 'tabpanel' } + #create-from-template-pane.tab-pane.js-toggle-container.px-0.pb-0{ class: active_when(active_tab == 'template'), role: 'tabpanel' } .card-slim.m-4.p-4 %div - contributing_templates_url = 'https://gitlab.com/gitlab-org/project-templates/contributing' diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml index 044adb75bea..407de590efb 100644 --- a/app/views/projects/notes/_actions.html.haml +++ b/app/views/projects/notes/_actions.html.haml @@ -39,7 +39,7 @@ - if can?(current_user, :award_emoji, note) - if note.emoji_awardable? .note-actions-item - = button_tag title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji} has-tooltip btn btn-transparent", data: { position: 'right', container: 'body' } do + = button_tag title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji has-tooltip btn btn-transparent", data: { position: 'right', container: 'body' } do = icon('spinner spin') %span{ class: 'link-highlight award-control-icon-neutral' }= sprite_icon('slight-smile') %span{ class: 'link-highlight award-control-icon-positive' }= sprite_icon('smiley') diff --git a/app/views/projects/pages_domains/_form.html.haml b/app/views/projects/pages_domains/_form.html.haml index e7edb93f05b..5b657966909 100644 --- a/app/views/projects/pages_domains/_form.html.haml +++ b/app/views/projects/pages_domains/_form.html.haml @@ -11,7 +11,7 @@ - if Gitlab.config.pages.external_https - - auto_ssl_available = ::Gitlab::LetsEncrypt::Client.new.enabled? + - auto_ssl_available = ::Gitlab::LetsEncrypt.enabled?(@domain) - auto_ssl_enabled = @domain.auto_ssl_enabled? - auto_ssl_available_and_enabled = auto_ssl_available && auto_ssl_enabled 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/_issuable_meta_data.html.haml b/app/views/shared/_issuable_meta_data.html.haml index 31a5370a5f8..71b13a5d741 100644 --- a/app/views/shared/_issuable_meta_data.html.haml +++ b/app/views/shared/_issuable_meta_data.html.haml @@ -2,7 +2,7 @@ - issue_votes = @issuable_meta_data[issuable.id] - upvotes, downvotes = issue_votes.upvotes, issue_votes.downvotes - issuable_url = @collection_type == "Issue" ? issue_path(issuable, anchor: 'notes') : merge_request_path(issuable, anchor: 'notes') -- issuable_mr = @issuable_meta_data[issuable.id].merge_requests_count +- issuable_mr = @issuable_meta_data[issuable.id].merge_requests_count(current_user) - if issuable_mr > 0 %li.issuable-mr.d-none.d-sm-block.has-tooltip{ title: _('Related merge requests') } diff --git a/app/views/shared/_issues.html.haml b/app/views/shared/_issues.html.haml index 987a5d4f13f..a21dcabb485 100644 --- a/app/views/shared/_issues.html.haml +++ b/app/views/shared/_issues.html.haml @@ -1,6 +1,6 @@ - if @issues.to_a.any? .card.card-small.card-without-border - %ul.content-list.issues-list.issuable-list + %ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position'), data: { group_full_path: @group&.full_path } } = render partial: 'projects/issues/issue', collection: @issues = paginate @issues, theme: "gitlab" - else diff --git a/app/views/shared/_personal_access_tokens_created_container.html.haml b/app/views/shared/_personal_access_tokens_created_container.html.haml index a8d3de66418..42989b145a2 100644 --- a/app/views/shared/_personal_access_tokens_created_container.html.haml +++ b/app/views/shared/_personal_access_tokens_created_container.html.haml @@ -1,5 +1,5 @@ -- container_title = local_assigns.fetch(:container_title, 'Your New Personal Access Token') -- clipboard_button_title = local_assigns.fetch(:clipboard_button_title, 'Copy personal access token to clipboard') +- container_title = local_assigns.fetch(:container_title, _('Your New Personal Access Token')) +- clipboard_button_title = local_assigns.fetch(:clipboard_button_title, _('Copy personal access token to clipboard')) .created-personal-access-token-container %h5.prepend-top-0 @@ -9,6 +9,7 @@ = text_field_tag 'created-personal-access-token', new_token_value, readonly: true, class: "qa-created-personal-access-token form-control js-select-on-focus", 'aria-describedby' => "created-token-help-block" %span.input-group-append = clipboard_button(text: new_token_value, title: clipboard_button_title, placement: "left", class: "input-group-text btn-default btn-clipboard") - %span#created-token-help-block.form-text.text-muted.text-danger Make sure you save it - you won't be able to access it again. + %span#created-token-help-block.form-text.text-muted.text-danger + = _("Make sure you save it - you won't be able to access it again.") %hr diff --git a/app/views/shared/_personal_access_tokens_form.html.haml b/app/views/shared/_personal_access_tokens_form.html.haml index 0891b3459ec..1d96feda3b0 100644 --- a/app/views/shared/_personal_access_tokens_form.html.haml +++ b/app/views/shared/_personal_access_tokens_form.html.haml @@ -1,9 +1,9 @@ -- type = impersonation ? "impersonation" : "personal access" +- type = impersonation ? s_('Profiles|impersonation') : s_('Profiles|personal access') %h5.prepend-top-0 - Add a #{type} token + = _('Add a %{type} token') % { type: type } %p.profile-settings-content - Pick a name for the application, and we'll give you a unique #{type} token. + = _("Pick a name for the application, and we'll give you a unique %{type} token.") % { type: type } = form_for token, url: path, method: :post, html: { class: 'js-requires-input' } do |f| @@ -11,19 +11,19 @@ .row .form-group.col-md-6 - = f.label :name, class: 'label-bold' + = f.label :name, _('Name'), class: 'label-bold' = f.text_field :name, class: "form-control qa-personal-access-token-name-field", required: true .row .form-group.col-md-6 - = f.label :expires_at, class: 'label-bold' + = f.label :expires_at, _('Expires at'), class: 'label-bold' .input-icon-wrapper = f.text_field :expires_at, class: "datepicker form-control", placeholder: 'YYYY-MM-DD' = icon('calendar', { class: 'input-icon-right' }) .form-group - = f.label :scopes, class: 'label-bold' + = f.label :scopes, _('Scopes'), class: 'label-bold' = render 'shared/tokens/scopes_form', prefix: 'personal_access_token', token: token, scopes: scopes .prepend-top-default - = f.submit "Create #{type} token", class: "btn btn-success qa-create-token-button" + = f.submit _('Create %{type} token') % { type: type }, class: "btn btn-success qa-create-token-button" diff --git a/app/views/shared/_personal_access_tokens_table.html.haml b/app/views/shared/_personal_access_tokens_table.html.haml index 49f3aae0f98..823117f37ca 100644 --- a/app/views/shared/_personal_access_tokens_table.html.haml +++ b/app/views/shared/_personal_access_tokens_table.html.haml @@ -1,20 +1,21 @@ -- type = impersonation ? "Impersonation" : "Personal Access" +- type = impersonation ? s_('Profiles|Impersonation') : s_('Profiles|Personal Access') %hr -%h5 Active #{type} Tokens (#{active_tokens.length}) +%h5 + = _('Active %{type} Tokens (%{token_length})') % { type: type, token_length: active_tokens.length } - if impersonation %p.profile-settings-content - To see all the user's personal access tokens you must impersonate them first. + = _("To see all the user's personal access tokens you must impersonate them first.") - if active_tokens.present? .table-responsive %table.table.active-tokens %thead %tr - %th Name - %th Created - %th Expires - %th Scopes + %th= _('Name') + %th= s_('AccessTokens|Created') + %th= _('Expires') + %th= _('Scopes') %th %tbody - active_tokens.each do |token| @@ -26,10 +27,10 @@ %span{ class: ('text-warning' if token.expires_soon?) } In #{distance_of_time_in_words_to_now(token.expires_at)} - else - %span.token-never-expires-label Never - %td= token.scopes.present? ? token.scopes.join(", ") : "<no scopes selected>" + %span.token-never-expires-label= _('Never') + %td= token.scopes.present? ? token.scopes.join(", ") : _('<no scopes selected>') - path = impersonation ? revoke_admin_user_impersonation_token_path(token.user, token) : revoke_profile_personal_access_token_path(token) - %td= link_to "Revoke", path, method: :put, class: "btn btn-danger float-right qa-revoke-button", data: { confirm: "Are you sure you want to revoke this #{type} Token? This action cannot be undone." } + %td= link_to _('Revoke'), path, method: :put, class: "btn btn-danger float-right qa-revoke-button", data: { confirm: _('Are you sure you want to revoke this %{type} Token? This action cannot be undone.') % { type: type } } - else .settings-message.text-center - This user has no active #{type} Tokens. + = _('This user has no active %{type} Tokens.') % { type: type } 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/boards/components/sidebar/_time_tracker.html.haml b/app/views/shared/boards/components/sidebar/_time_tracker.html.haml index b76d44c5907..43081499920 100644 --- a/app/views/shared/boards/components/sidebar/_time_tracker.html.haml +++ b/app/views/shared/boards/components/sidebar/_time_tracker.html.haml @@ -3,4 +3,5 @@ ":time-spent" => "issue.timeSpent || 0", ":human-time-estimate" => "issue.humanTimeEstimate", ":human-time-spent" => "issue.humanTimeSpent", + ":limit-to-hours" => "timeTrackingLimitToHours", "root-path" => "#{root_url}" } diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index c6a391ae563..1bd56e064d5 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -48,13 +48,13 @@ = hidden_field_tag 'merge_request_to_resolve_discussions_of', @merge_request_to_resolve_discussions_of.iid - if @discussion_to_resolve = hidden_field_tag 'discussion_to_resolve', @discussion_to_resolve.id - Creating this issue will resolve the discussion in + Creating this issue will resolve the thread in - else - Creating this issue will resolve all discussions in + Creating this issue will resolve all threads in = link_to_discussions_to_resolve(@merge_request_to_resolve_discussions_of, @discussion_to_resolve) - else The - = @discussion_to_resolve ? 'discussion' : 'discussions' + = @discussion_to_resolve ? 'thread' : 'threads' at = link_to_discussions_to_resolve(@merge_request_to_resolve_discussions_of, @discussion_to_resolve) will stay unresolved. Ask someone with permission to resolve diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml index 1dd97bc4ed1..403e001bfe8 100644 --- a/app/views/shared/issuable/_sort_dropdown.html.haml +++ b/app/views/shared/issuable/_sort_dropdown.html.haml @@ -1,6 +1,7 @@ - sort_value = @sort - sort_title = issuable_sort_option_title(sort_value) - viewing_issues = controller.controller_name == 'issues' || controller.action_name == 'issues' +- manual_sorting = viewing_issues && controller.controller_name != 'dashboard' && Feature.enabled?(:manual_sorting) .dropdown.inline.prepend-left-10.issue-sort-dropdown .btn-group{ role: 'group' } @@ -17,6 +18,6 @@ = sortable_item(sort_title_due_date, page_filter_path(sort: sort_value_due_date), sort_title) if viewing_issues = sortable_item(sort_title_popularity, page_filter_path(sort: sort_value_popularity), sort_title) = sortable_item(sort_title_label_priority, page_filter_path(sort: sort_value_label_priority), sort_title) - = sortable_item(sort_title_relative_position, page_filter_path(sort: sort_value_relative_position), sort_title) if viewing_issues && Feature.enabled?(:manual_sorting) + = sortable_item(sort_title_relative_position, page_filter_path(sort: sort_value_relative_position), sort_title) if manual_sorting = render_if_exists('shared/ee/issuable/sort_dropdown', viewing_issues: viewing_issues, sort_title: sort_title) = issuable_sort_direction_button(sort_value) diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index b24075c7849..ced6af50501 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -93,7 +93,11 @@ = milestone.issues_visible_to_user(current_user).closed.count .block - #issuable-time-tracker{ data: { time_estimate: @milestone.total_issue_time_estimate, time_spent: @milestone.total_issue_time_spent, human_time_estimate: @milestone.human_total_issue_time_estimate, human_time_spent: @milestone.human_total_issue_time_spent } } + #issuable-time-tracker{ data: { time_estimate: @milestone.total_issue_time_estimate, + time_spent: @milestone.total_issue_time_spent, + human_time_estimate: @milestone.human_total_issue_time_estimate, + human_time_spent: @milestone.human_total_issue_time_spent, + limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s } } // Fallback while content is loading .title.hide-collapsed = _('Time tracking') diff --git a/app/views/shared/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml index c3f5eeb0da6..8d74eacc7dc 100644 --- a/app/views/shared/notes/_comment_button.html.haml +++ b/app/views/shared/notes/_comment_button.html.haml @@ -18,11 +18,11 @@ %li.divider.droplab-item-ignore - %li#discussion{ data: { value: 'DiscussionNote', 'submit-text' => _('Start discussion'), 'close-text' => _("Start discussion & close %{noteable_name}") % { noteable_name: noteable_name }, 'reopen-text' => _("Start discussion & reopen %{noteable_name}") % { noteable_name: noteable_name } } } + %li#discussion{ data: { value: 'DiscussionNote', 'submit-text' => _('Start thread'), 'close-text' => _("Start thread & close %{noteable_name}") % { noteable_name: noteable_name }, 'reopen-text' => _("Start thread & reopen %{noteable_name}") % { noteable_name: noteable_name } } } %button.btn.btn-transparent = icon('check', class: 'icon') .description - %strong= _("Start discussion") + %strong= _("Start thread") %p = succeed '.' do - if @note.noteable.supports_resolvable_notes? 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/views/shared/tokens/_scopes_list.html.haml b/app/views/shared/tokens/_scopes_list.html.haml index f99e905e95c..428861485b4 100644 --- a/app/views/shared/tokens/_scopes_list.html.haml +++ b/app/views/shared/tokens/_scopes_list.html.haml @@ -4,7 +4,7 @@ %tr %td - Scopes + = _('Scopes') %td %ul.scopes-list.append-bottom-0 - token.scopes.each do |scope| diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index fd0cc5fb24e..3d34bfc05c7 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -9,6 +9,7 @@ - cronjob:import_export_project_cleanup - cronjob:pages_domain_verification_cron - cronjob:pages_domain_removal_cron +- cronjob:pages_domain_ssl_renewal_cron - cronjob:pipeline_schedule - cronjob:prune_old_events - cronjob:remove_expired_group_links @@ -25,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 @@ -100,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 @@ -133,6 +138,7 @@ - new_note - pages - pages_domain_verification +- pages_domain_ssl_renewal - plugin - post_receive - process_commit 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 diff --git a/app/workers/pages_domain_ssl_renewal_cron_worker.rb b/app/workers/pages_domain_ssl_renewal_cron_worker.rb new file mode 100644 index 00000000000..40c34d29970 --- /dev/null +++ b/app/workers/pages_domain_ssl_renewal_cron_worker.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class PagesDomainSslRenewalCronWorker + include ApplicationWorker + include CronjobQueue + + def perform + PagesDomain.need_auto_ssl_renewal.find_each do |domain| + next unless ::Gitlab::LetsEncrypt.enabled?(domain) + + PagesDomainSslRenewalWorker.perform_async(domain.id) + end + end +end diff --git a/app/workers/pages_domain_ssl_renewal_worker.rb b/app/workers/pages_domain_ssl_renewal_worker.rb new file mode 100644 index 00000000000..b32458ca777 --- /dev/null +++ b/app/workers/pages_domain_ssl_renewal_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class PagesDomainSslRenewalWorker + include ApplicationWorker + + def perform(domain_id) + domain = PagesDomain.find_by_id(domain_id) + return unless domain&.enabled? + return unless ::Gitlab::LetsEncrypt.enabled?(domain) + + ::PagesDomains::ObtainLetsEncryptCertificateService.new(domain).execute + end +end |