diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-01-21 17:21:10 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-01-21 17:21:10 +0300 |
commit | cb0d23c455b73486fd1015f8ca9479b5b7e3585d (patch) | |
tree | d7dc129a407fd74266d2dc561bebf24665197c2f /app | |
parent | c3e911be175c0aabfea1eb030f9e0ef23f5f3887 (diff) |
Add latest changes from gitlab-org/gitlab@12-7-stable-ee
Diffstat (limited to 'app')
660 files changed, 9935 insertions, 4020 deletions
diff --git a/app/assets/images/ext_snippet_icons/ext_snippet_icons.png b/app/assets/images/ext_snippet_icons/ext_snippet_icons.png Binary files differindex 20380adc4e5..c864e558bfd 100644 --- a/app/assets/images/ext_snippet_icons/ext_snippet_icons.png +++ b/app/assets/images/ext_snippet_icons/ext_snippet_icons.png diff --git a/app/assets/images/ext_snippet_icons/logo.png b/app/assets/images/ext_snippet_icons/logo.png Binary files differdeleted file mode 100644 index 794c9cc2dbc..00000000000 --- a/app/assets/images/ext_snippet_icons/logo.png +++ /dev/null diff --git a/app/assets/images/ext_snippet_icons/logo.svg b/app/assets/images/ext_snippet_icons/logo.svg new file mode 100644 index 00000000000..9cb3042213a --- /dev/null +++ b/app/assets/images/ext_snippet_icons/logo.svg @@ -0,0 +1 @@ +<svg width="100" height="32" xmlns="http://www.w3.org/2000/svg"><g fill-rule="nonzero" fill="none"><path fill="#8C929D" d="M67.67 8.11h-2.06l.009 15.364h8.348v-1.9H67.68l-.01-13.465zM81.913 20.778a3.517 3.517 0 01-2.553 1.078c-1.57 0-2.203-.775-2.203-1.787 0-1.522 1.059-2.25 3.309-2.25.487.002.974.04 1.456.113v2.846h-.01zm-2.137-9.313a6.826 6.826 0 00-4.387 1.579l.728 1.267c.841-.492 1.872-.983 3.356-.983 1.693 0 2.44.87 2.44 2.326v.747a9.4 9.4 0 00-1.428-.114c-3.612 0-5.446 1.267-5.446 3.914 0 2.374 1.456 3.565 3.659 3.565 1.484 0 2.912-.68 3.404-1.787l.378 1.503h1.456v-7.866c-.01-2.487-1.087-4.151-4.16-4.151zM90.587 21.926c-.776 0-1.456-.094-1.967-.33v-7.102c.7-.586 1.57-1.011 2.676-1.011 1.995 0 2.76 1.408 2.76 3.687 0 3.234-1.238 4.756-3.47 4.756m.87-10.457a3.775 3.775 0 00-2.836 1.257V10.74l-.01-2.629h-2.013l.01 14.987c1.01.425 2.391.652 3.895.652 3.848 0 5.701-2.458 5.701-6.704-.01-3.356-1.72-5.578-4.746-5.578M45.228 9.776c1.825 0 3.006.605 3.772 1.22l.889-1.541c-1.2-1.06-2.827-1.627-4.567-1.627-4.387 0-7.46 2.676-7.46 8.075 0 5.654 3.319 7.857 7.11 7.857a12.083 12.083 0 004.577-.888L49.5 16.83v-1.9h-5.63v1.9h3.594l.047 4.586c-.473.236-1.286.425-2.392.425-3.045 0-5.087-1.92-5.087-5.957-.01-4.113 2.1-6.108 5.19-6.108M59.744 8.107H57.73l.01 2.582v8.916c0 2.487 1.078 4.15 4.15 4.15.416.002.83-.036 1.24-.113v-1.806c-.31.047-.624.07-.937.066-1.692 0-2.44-.87-2.44-2.326v-6.145h3.376v-1.683h-3.373l-.009-3.64h-.003zM52.608 23.474h2.014V11.75h-2.014zM52.608 10.133h2.014V8.119h-2.014z"/><path d="M31.864 17.907l-1.788-5.496-3.538-10.9a.612.612 0 00-1.16 0L21.84 12.406H10.085L6.547 1.512a.612.612 0 00-1.16 0L1.855 12.405.066 17.907c-.162.5.015 1.05.44 1.36L15.963 30.5l15.456-11.233a1.22 1.22 0 00.446-1.36" fill="#FC6D26"/><path d="M15.966 30.49l5.875-18.086H10.09z" fill="#E24329"/><path d="M15.962 30.49l-5.877-18.086H1.859z" fill="#FC6D26"/><path d="M1.852 12.41L.063 17.906c-.162.5.015 1.05.441 1.36L15.959 30.5 1.852 12.41z" fill="#FCA326"/><path d="M1.854 12.41h8.237L6.546 1.517a.612.612 0 00-1.16 0L1.854 12.41z" fill="#E24329"/><path d="M15.966 30.49l5.875-18.086h8.236z" fill="#FC6D26"/><path d="M30.074 12.41l1.79 5.496a1.219 1.219 0 01-.44 1.36L15.966 30.49l14.107-18.08z" fill="#FCA326"/><path d="M30.079 12.41H21.84L25.38 1.517a.612.612 0 011.16 0l3.539 10.893z" fill="#E24329"/></g></svg>
\ No newline at end of file diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 071ae8ca8cf..bee079c6643 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -54,10 +54,15 @@ const Api = { }); }, - groupMembers(id) { + groupMembers(id, options) { const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id)); - return axios.get(url); + return axios.get(url, { + params: { + per_page: DEFAULT_PER_PAGE, + ...options, + }, + }); }, // Return groups list. Filtered by query @@ -142,6 +147,12 @@ const Api = { return axios.get(url); }, + // Update a single project + updateProject(projectPath, data) { + const url = Api.buildUrl(Api.projectPath).replace(':id', encodeURIComponent(projectPath)); + return axios.put(url, data); + }, + /** * Get all projects for a forked relationship to a specified project * @param {string} projectPath - Path or ID of a project diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index aaab217964c..0e403d023df 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -2,13 +2,13 @@ import $ from 'jquery'; import _ from 'underscore'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Cookies from 'js-cookie'; import { __ } from './locale'; import { updateTooltipTitle } from './lib/utils/common_utils'; import { isInVueNoteablePage } from './lib/utils/dom_utils'; import flash from './flash'; import axios from './lib/utils/axios_utils'; -import bp from './breakpoints'; const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd'; const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd'; @@ -266,7 +266,7 @@ export class AwardsHandler { top: `${$addBtn.offset().top + $addBtn.outerHeight()}px`, }; // for xs screen we position the element on center - if (bp.getBreakpointSize() === 'xs') { + if (bp.getBreakpointSize() === 'xs' || bp.getBreakpointSize() === 'sm') { css.left = '5%'; } else if (position === 'right') { css.left = `${$addBtn.offset().left - $menu.outerWidth() + 20}px`; @@ -506,6 +506,8 @@ export class AwardsHandler { const options = { scrollTop: $('.awards').offset().top - 110, }; + + // eslint-disable-next-line no-jquery/no-animate return $('body, html').animate(options, 200); } diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js index 318b7f77c7b..03c1b5a0169 100644 --- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js @@ -183,7 +183,7 @@ export class CopyAsGFM { } // Export CopyAsGFM as a global for rspec to access -// see /spec/features/copy_as_gfm_spec.rb +// see /spec/features/markdown/copy_as_gfm_spec.rb if (process.env.NODE_ENV !== 'production') { window.CopyAsGFM = CopyAsGFM; } diff --git a/app/assets/javascripts/behaviors/markdown/editor_extensions.js b/app/assets/javascripts/behaviors/markdown/editor_extensions.js index 8bd2145db1c..308e31e7047 100644 --- a/app/assets/javascripts/behaviors/markdown/editor_extensions.js +++ b/app/assets/javascripts/behaviors/markdown/editor_extensions.js @@ -53,7 +53,7 @@ import InlineHTML from './marks/inline_html'; // The nodes and marks referenced here transform that same HTML to GFM to be copied to the clipboard. // Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML // from GFM should have a node or mark here. -// The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb. +// The GFM-to-HTML-to-GFM cycle is tested in spec/features/markdown/copy_as_gfm_spec.rb. export default [ new Doc(), diff --git a/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js b/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js new file mode 100644 index 00000000000..665a7216424 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js @@ -0,0 +1,122 @@ +const maxColumnWidth = (rows, columnIndex) => Math.max(...rows.map(row => row[columnIndex].length)); + +export default class PasteMarkdownTable { + constructor(clipboardData) { + this.data = clipboardData; + this.columnWidths = []; + this.rows = []; + this.tableFound = this.parseTable(); + } + + isTable() { + return this.tableFound; + } + + convertToTableMarkdown() { + this.calculateColumnWidths(); + + const markdownRows = this.rows.map( + row => + // | Name | Title | Email Address | + // |--------------|-------|----------------| + // | Jane Atler | CEO | jane@acme.com | + // | John Doherty | CTO | john@acme.com | + // | Sally Smith | CFO | sally@acme.com | + `| ${row.map((column, index) => this.formatColumn(column, index)).join(' | ')} |`, + ); + + // Insert a header break (e.g. -----) to the second row + markdownRows.splice(1, 0, this.generateHeaderBreak()); + + return markdownRows.join('\n'); + } + + // Private methods below + + // To determine whether the cut data is a table, the following criteria + // must be satisfied with the clipboard data: + // + // 1. MIME types "text/plain" and "text/html" exist + // 2. The "text/html" data must have a single <table> element + // 3. The number of rows in the "text/plain" data matches that of the "text/html" data + // 4. The max number of columns in "text/plain" matches that of the "text/html" data + parseTable() { + if (!this.data.types.includes('text/html') || !this.data.types.includes('text/plain')) { + return false; + } + + const htmlData = this.data.getData('text/html'); + this.doc = new DOMParser().parseFromString(htmlData, 'text/html'); + const tables = this.doc.querySelectorAll('table'); + + // We're only looking for exactly one table. If there happens to be + // multiple tables, it's possible an application copied data into + // the clipboard that is not related to a simple table. It may also be + // complicated converting multiple tables into Markdown. + if (tables.length !== 1) { + return false; + } + + const text = this.data.getData('text/plain').trim(); + const splitRows = text.split(/[\n\u0085\u2028\u2029]|\r\n?/g); + + // Now check that the number of rows matches between HTML and text + if (this.doc.querySelectorAll('tr').length !== splitRows.length) { + return false; + } + + this.rows = splitRows.map(row => row.split('\t')); + this.normalizeRows(); + + // Check that the max number of columns in the HTML matches the number of + // columns in the text. GitHub, for example, copies a line number and the + // line itself into the HTML data. + if (!this.columnCountsMatch()) { + return false; + } + + return true; + } + + // Ensure each row has the same number of columns + normalizeRows() { + const rowLengths = this.rows.map(row => row.length); + const maxLength = Math.max(...rowLengths); + + this.rows.forEach(row => { + while (row.length < maxLength) { + row.push(''); + } + }); + } + + calculateColumnWidths() { + this.columnWidths = this.rows[0].map((_column, columnIndex) => + maxColumnWidth(this.rows, columnIndex), + ); + } + + columnCountsMatch() { + const textColumnCount = this.rows[0].length; + let htmlColumnCount = 0; + + this.doc.querySelectorAll('table tr').forEach(row => { + htmlColumnCount = Math.max(row.cells.length, htmlColumnCount); + }); + + return textColumnCount === htmlColumnCount; + } + + formatColumn(column, index) { + const spaces = Array(this.columnWidths[index] - column.length + 1).join(' '); + return column + spaces; + } + + generateHeaderBreak() { + // Add 3 dashes to line things up: there is additional spacing for the pipe characters + const dashes = this.columnWidths.map((width, index) => + Array(this.columnWidths[index] + 3).join('-'), + ); + return `|${dashes.join('|')}|`; + } +} diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js index 8ebdfede8f7..a6deb656b37 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -3,7 +3,7 @@ import Sortable from 'sortablejs'; import Vue from 'vue'; import { GlButtonGroup, GlButton, GlTooltip } from '@gitlab/ui'; import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits'; -import { n__, s__ } from '~/locale'; +import { s__, __, sprintf } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import Tooltip from '~/vue_shared/directives/tooltip'; import AccessorUtilities from '../../lib/utils/accessor'; @@ -67,10 +67,13 @@ export default Vue.extend({ !this.disabled && this.list.type !== ListType.closed && this.list.type !== ListType.blank ); }, - counterTooltip() { + issuesTooltip() { const { issuesSize } = this.list; - return `${n__('%d issue', '%d issues', issuesSize)}`; + + return sprintf(__('%{issuesSize} issues'), { issuesSize }); }, + // Only needed to make karma pass. + weightCountToolTip() {}, // eslint-disable-line vue/return-in-computed-property caretTooltip() { return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand'); }, diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 1e54d4d6b7d..ee889e0f7e0 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -256,7 +256,7 @@ export default { let toList; if (to) { const containerEl = to.closest('.js-board-list'); - toList = boardsStore.findList('id', Number(containerEl.dataset.board)); + toList = boardsStore.findList('id', Number(containerEl.dataset.board), ''); } /** diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 5d7be0c705a..eeb0fbec1ed 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -9,7 +9,6 @@ import { GlDropdownItem, } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; import httpStatusCodes from '~/lib/utils/http_status'; import boardsStore from '../stores/boards_store'; import BoardForm from './board_form.vue'; @@ -19,7 +18,6 @@ const MIN_BOARDS_TO_VIEW_RECENT = 10; export default { name: 'BoardsSelector', components: { - Icon, BoardForm, GlLoadingIcon, GlSearchBoxByType, diff --git a/app/assets/javascripts/boards/components/modal/list.vue b/app/assets/javascripts/boards/components/modal/list.vue index 1802b543687..78e3351a79e 100644 --- a/app/assets/javascripts/boards/components/modal/list.vue +++ b/app/assets/javascripts/boards/components/modal/list.vue @@ -1,6 +1,6 @@ <script> +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Icon from '~/vue_shared/components/icon.vue'; -import bp from '../../../breakpoints'; import ModalStore from '../../stores/modal_store'; import IssueCardInner from '../issue_card_inner.vue'; @@ -105,9 +105,9 @@ export default { setColumnCount() { const breakpoint = bp.getBreakpointSize(); - if (breakpoint === 'lg' || breakpoint === 'md') { + if (breakpoint === 'xl' || breakpoint === 'lg') { this.columns = 3; - } else if (breakpoint === 'sm') { + } else if (breakpoint === 'md') { this.columns = 2; } else { this.columns = 1; diff --git a/app/assets/javascripts/breakpoints.js b/app/assets/javascripts/breakpoints.js deleted file mode 100644 index 93aacba0e8e..00000000000 --- a/app/assets/javascripts/breakpoints.js +++ /dev/null @@ -1,22 +0,0 @@ -export const breakpoints = { - lg: 1200, - md: 992, - sm: 768, - xs: 0, -}; - -const BreakpointInstance = { - windowWidth: () => window.innerWidth, - getBreakpointSize() { - const windowWidth = this.windowWidth(); - - const breakpoint = Object.keys(breakpoints).find(key => windowWidth > breakpoints[key]); - - return breakpoint; - }, - isDesktop() { - return ['lg', 'md'].includes(this.getBreakpointSize()); - }, -}; - -export default BreakpointInstance; diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index d990d2677a8..b764348eb3c 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -53,6 +53,7 @@ export default class Clusters { helpPath, ingressHelpPath, ingressDnsHelpPath, + ingressModSecurityHelpPath, environmentsHelpPath, clustersHelpPath, deployBoardsHelpPath, @@ -69,6 +70,7 @@ export default class Clusters { helpPath, ingressHelpPath, ingressDnsHelpPath, + ingressModSecurityHelpPath, environmentsHelpPath, clustersHelpPath, deployBoardsHelpPath, @@ -169,6 +171,7 @@ export default class Clusters { ingressHelpPath: this.state.ingressHelpPath, managePrometheusPath: this.state.managePrometheusPath, ingressDnsHelpPath: this.state.ingressDnsHelpPath, + ingressModSecurityHelpPath: this.state.ingressModSecurityHelpPath, cloudRunHelpPath: this.state.cloudRunHelpPath, providerType: this.state.providerType, preInstalledKnative: this.state.preInstalledKnative, diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index c6c8dc6352c..7db9898396b 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -2,7 +2,6 @@ /* eslint-disable vue/require-default-prop */ /* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { GlLink, GlModalDirective } from '@gitlab/ui'; -import TimeagoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; import { s__, __, sprintf } from '~/locale'; import eventHub from '../event_hub'; import identicon from '../../vue_shared/components/identicon.vue'; @@ -16,7 +15,6 @@ export default { components: { loadingButton, identicon, - TimeagoTooltip, GlLink, UninstallApplicationButton, UninstallApplicationConfirmationModal, @@ -292,6 +290,7 @@ export default { disabled && 'cluster-application-disabled', ]" class="cluster-application-row gl-responsive-table-row gl-responsive-table-row-col-span" + :data-qa-selector="id" > <div class="gl-responsive-table-row-layout" role="row"> <div class="table-section append-right-8 section-align-top" role="gridcell"> @@ -383,12 +382,16 @@ export default { :disabled="disabled || installButtonDisabled" :label="installButtonLabel" class="js-cluster-application-install-button" + data-qa-selector="install_button" + :data-qa-application="id" @click="installClicked" /> <uninstall-application-button v-if="displayUninstallButton" v-gl-modal-directive="'uninstall-' + id" :status="status" + data-qa-selector="uninstall_button" + :data-qa-application="id" class="js-cluster-application-uninstall-button" /> <uninstall-application-confirmation-modal diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index a0ab20a97aa..704515cf70c 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -19,7 +19,6 @@ import applicationRow from './application_row.vue'; import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; import KnativeDomainEditor from './knative_domain_editor.vue'; import { CLUSTER_TYPE, PROVIDER_TYPE, APPLICATION_STATUS, INGRESS } from '../constants'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; import eventHub from '~/clusters/event_hub'; import CrossplaneProviderStack from './crossplane_provider_stack.vue'; @@ -27,7 +26,6 @@ export default { components: { applicationRow, clipboardButton, - LoadingButton, GlLoadingIcon, KnativeDomainEditor, CrossplaneProviderStack, @@ -58,6 +56,11 @@ export default { required: false, default: '', }, + ingressModSecurityHelpPath: { + type: String, + required: false, + default: '', + }, cloudRunHelpPath: { type: String, required: false, @@ -114,6 +117,9 @@ export default { ingressInstalled() { return this.applications.ingress.status === APPLICATION_STATUS.INSTALLED; }, + ingressEnableModsecurity() { + return this.applications.ingress.modsecurity_enabled; + }, ingressExternalEndpoint() { return this.applications.ingress.externalIp || this.applications.ingress.externalHostname; }, @@ -123,12 +129,21 @@ export default { crossplaneInstalled() { return this.applications.crossplane.status === APPLICATION_STATUS.INSTALLED; }, - enableClusterApplicationCrossplane() { - return gon.features && gon.features.enableClusterApplicationCrossplane; - }, enableClusterApplicationElasticStack() { return gon.features && gon.features.enableClusterApplicationElasticStack; }, + ingressModSecurityDescription() { + const escapedUrl = _.escape(this.ingressModSecurityHelpPath); + + return sprintf( + s__('ClusterIntegration|Learn more about %{startLink}ModSecurity%{endLink}'), + { + startLink: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`, + endLink: '</a>', + }, + false, + ); + }, ingressDescription() { return sprintf( _.escape( @@ -137,9 +152,9 @@ export default { ), ), { - pricingLink: `<strong><a href="https://cloud.google.com/compute/pricing#lb" + pricingLink: `<a href="https://cloud.google.com/compute/pricing#lb" target="_blank" rel="noopener noreferrer"> - ${_.escape(s__('ClusterIntegration|pricing'))}</a></strong>`, + ${_.escape(s__('ClusterIntegration|pricing'))}</a>`, }, false, ); @@ -204,9 +219,6 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity elasticStackInstalled() { return this.applications.elastic_stack.status === APPLICATION_STATUS.INSTALLED; }, - elasticStackKibanaHostname() { - return this.applications.elastic_stack.kibana_hostname; - }, knative() { return this.applications.knative; }, @@ -313,6 +325,9 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity :request-reason="applications.ingress.requestReason" :installed="applications.ingress.installed" :install-failed="applications.ingress.installFailed" + :install-application-request-params="{ + modsecurity_enabled: applications.ingress.modsecurity_enabled, + }" :uninstallable="applications.ingress.uninstallable" :uninstall-successful="applications.ingress.uninstallSuccessful" :uninstall-failed="applications.ingress.uninstallFailed" @@ -328,6 +343,26 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity }} </p> + <template> + <div class="form-group"> + <div class="form-check form-check-inline"> + <input + v-model="applications.ingress.modsecurity_enabled" + :disabled="ingressInstalled" + type="checkbox" + autocomplete="off" + class="form-check-input" + /> + <label class="form-check-label label-bold" for="ingress-enable-modsecurity"> + {{ s__('ClusterIntegration|Enable Web Application Firewall') }} + </label> + </div> + <p class="form-text text-muted"> + <strong v-html="ingressModSecurityDescription"></strong> + </p> + </div> + </template> + <template v-if="ingressInstalled"> <div class="form-group"> <label for="ingress-endpoint">{{ s__('ClusterIntegration|Ingress Endpoint') }}</label> @@ -377,7 +412,9 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity </p> </template> <template v-if="!ingressInstalled"> - <div class="bs-callout bs-callout-info" v-html="ingressDescription"></div> + <div class="bs-callout bs-callout-info"> + <strong v-html="ingressDescription"></strong> + </div> </template> </div> </application-row> @@ -479,7 +516,6 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity </div> </application-row> <application-row - v-if="enableClusterApplicationCrossplane" id="crossplane" :logo-url="crossplaneLogo" :title="applications.crossplane.title" @@ -638,9 +674,6 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity :uninstall-successful="applications.elastic_stack.uninstallSuccessful" :uninstall-failed="applications.elastic_stack.uninstallFailed" :disabled="!helmInstalled" - :install-application-request-params="{ - kibana_hostname: applications.elastic_stack.kibana_hostname, - }" title-link="https://github.com/helm/charts/tree/master/stable/elastic-stack" > <div slot="description"> @@ -651,40 +684,6 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity ) }} </p> - - <template v-if="ingressExternalEndpoint"> - <div class="form-group"> - <label for="elastic-stack-kibana-hostname">{{ - s__('ClusterIntegration|Kibana Hostname') - }}</label> - - <div class="input-group"> - <input - v-model="applications.elastic_stack.kibana_hostname" - :readonly="elasticStackInstalled" - type="text" - class="form-control js-hostname" - /> - <span class="input-group-btn"> - <clipboard-button - :text="elasticStackKibanaHostname" - :title="s__('ClusterIntegration|Copy Kibana Hostname')" - class="js-clipboard-btn" - /> - </span> - </div> - - <p v-if="ingressInstalled" class="form-text text-muted"> - {{ - s__(`ClusterIntegration|Replace this with your own hostname if you want. - If you do so, point hostname to Ingress IP Address from above.`) - }} - <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> - {{ __('More information') }} - </a> - </p> - </div> - </template> </div> </application-row> </div> diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index 35dbf951551..26456fb28db 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -5,7 +5,6 @@ import { JUPYTER, KNATIVE, CERT_MANAGER, - ELASTIC_STACK, CROSSPLANE, RUNNER, APPLICATION_INSTALLED_STATUSES, @@ -52,6 +51,7 @@ export default class ClusterStore { ingress: { ...applicationInitialState, title: s__('ClusterIntegration|Ingress'), + modsecurity_enabled: false, externalIp: null, externalHostname: null, }, @@ -96,7 +96,6 @@ export default class ClusterStore { elastic_stack: { ...applicationInitialState, title: s__('ClusterIntegration|Elastic Stack'), - kibana_hostname: null, }, }, environments: [], @@ -108,6 +107,7 @@ export default class ClusterStore { helpPath, ingressHelpPath, ingressDnsHelpPath, + ingressModSecurityHelpPath, environmentsHelpPath, clustersHelpPath, deployBoardsHelpPath, @@ -116,6 +116,7 @@ export default class ClusterStore { this.state.helpPath = helpPath; this.state.ingressHelpPath = ingressHelpPath; this.state.ingressDnsHelpPath = ingressDnsHelpPath; + this.state.ingressModSecurityHelpPath = ingressModSecurityHelpPath; this.state.environmentsHelpPath = environmentsHelpPath; this.state.clustersHelpPath = clustersHelpPath; this.state.deployBoardsHelpPath = deployBoardsHelpPath; @@ -207,6 +208,8 @@ export default class ClusterStore { if (appId === INGRESS) { this.state.applications.ingress.externalIp = serverAppEntry.external_ip; this.state.applications.ingress.externalHostname = serverAppEntry.external_hostname; + this.state.applications.ingress.modsecurity_enabled = + serverAppEntry.modsecurity_enabled || this.state.applications.ingress.modsecurity_enabled; } else if (appId === CERT_MANAGER) { this.state.applications.cert_manager.email = this.state.applications.cert_manager.email || serverAppEntry.email; @@ -231,12 +234,6 @@ export default class ClusterStore { } else if (appId === RUNNER) { this.state.applications.runner.version = version; this.state.applications.runner.updateAvailable = updateAvailable; - } else if (appId === ELASTIC_STACK) { - this.state.applications.elastic_stack.kibana_hostname = this.updateHostnameIfUnset( - this.state.applications.elastic_stack.kibana_hostname, - serverAppEntry.kibana_hostname, - 'kibana', - ); } }); } diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js index a28e17f7a56..fb8b1c17407 100644 --- a/app/assets/javascripts/commit/image_file.js +++ b/app/assets/javascripts/commit/image_file.js @@ -40,7 +40,10 @@ export default class ImageFile { .removeClass('active') .filter(`.${viewMode}`) .addClass('active'); + + // eslint-disable-next-line no-jquery/no-fade return $(`.view:visible:not(.${viewMode})`, this.file).fadeOut(200, () => { + // eslint-disable-next-line no-jquery/no-fade $(`.view.${viewMode}`, this.file).fadeIn(200); return this.initView(viewMode); }); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue index e5b030d4900..6b0d184faec 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import PipelinesService from '~/pipelines/services/pipelines_service'; import PipelineStore from '~/pipelines/stores/pipelines_store'; import pipelinesMixin from '~/pipelines/mixins/pipelines'; @@ -7,7 +8,6 @@ import eventHub from '~/pipelines/event_hub'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; import { getParameterByName } from '~/lib/utils/common_utils'; import CIPaginationMixin from '~/vue_shared/mixins/ci_pagination_api_mixin'; -import bp from '~/breakpoints'; export default { components: { diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js index f43b6f3d777..51879f280e0 100644 --- a/app/assets/javascripts/contextual_sidebar.js +++ b/app/assets/javascripts/contextual_sidebar.js @@ -1,13 +1,9 @@ import $ from 'jquery'; import Cookies from 'js-cookie'; import _ from 'underscore'; -import bp from './breakpoints'; +import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils'; import { parseBoolean } from '~/lib/utils/common_utils'; -// NOTE: at 1200px nav sidebar should not overlap the content -// https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/24555#note_134136110 -const NAV_SIDEBAR_BREAKPOINT = 1200; - export const SIDEBAR_COLLAPSED_CLASS = 'js-sidebar-collapsed'; export default class ContextualSidebar { @@ -50,9 +46,10 @@ export default class ContextualSidebar { $(window).on('resize', () => _.debounce(this.render(), 100)); } - // TODO: use the breakpoints from breakpoints.js once they have been updated for bootstrap 4 // See documentation: https://design.gitlab.com/regions/navigation#contextual-navigation - static isDesktopBreakpoint = () => bp.windowWidth() >= NAV_SIDEBAR_BREAKPOINT; + // NOTE: at 1200px nav sidebar should not overlap the content + // https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/24555#note_134136110 + static isDesktopBreakpoint = () => bp.windowWidth() >= breakpoints.xl; static setCollapsedCookie(value) { if (!ContextualSidebar.isDesktopBreakpoint()) { return; @@ -63,12 +60,13 @@ export default class ContextualSidebar { toggleSidebarNav(show) { const breakpoint = bp.getBreakpointSize(); const dbp = ContextualSidebar.isDesktopBreakpoint(); + const supportedSizes = ['xs', 'sm', 'md']; this.$sidebar.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show); this.$sidebar.toggleClass('sidebar-expanded-mobile', !dbp ? show : false); this.$overlay.toggleClass( 'mobile-nav-open', - breakpoint === 'xs' || breakpoint === 'sm' ? show : false, + supportedSizes.includes(breakpoint) ? show : false, ); this.$sidebar.removeClass('sidebar-collapsed-desktop'); } @@ -76,13 +74,14 @@ export default class ContextualSidebar { toggleCollapsedSidebar(collapsed, saveCookie) { const breakpoint = bp.getBreakpointSize(); const dbp = ContextualSidebar.isDesktopBreakpoint(); + const supportedSizes = ['xs', 'sm', 'md']; if (this.$sidebar.length) { this.$sidebar.toggleClass(`sidebar-collapsed-desktop ${SIDEBAR_COLLAPSED_CLASS}`, collapsed); this.$sidebar.toggleClass('sidebar-expanded-mobile', !dbp ? !collapsed : false); this.$page.toggleClass( 'page-with-icon-sidebar', - breakpoint === 'xs' || breakpoint === 'sm' ? true : collapsed, + supportedSizes.includes(breakpoint) ? true : collapsed, ); } diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue b/app/assets/javascripts/create_cluster/components/cluster_form_dropdown.vue index 2f7fcfcb755..2f7fcfcb755 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue +++ b/app/assets/javascripts/create_cluster/components/cluster_form_dropdown.vue diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue index d04d0ff2a6d..3d389cf3db5 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue @@ -3,7 +3,7 @@ import { createNamespacedHelpers, mapState, mapActions } from 'vuex'; import _ from 'underscore'; import { GlFormInput, GlFormCheckbox } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; -import ClusterFormDropdown from './cluster_form_dropdown.vue'; +import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue'; import { KUBERNETES_VERSIONS } from '../constants'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; @@ -149,11 +149,11 @@ export default { roleDropdownHelpText() { return sprintf( s__( - 'ClusterIntegration|Select the IAM Role to allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role name, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.', + 'ClusterIntegration|Your service role is distinct from the provision role used when authenticating. It will allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.', ), { startLink: - '<a href="https://docs.aws.amazon.com/eks/latest/userguide/getting-started-console.html#role-create" target="_blank" rel="noopener noreferrer">', + '<a href="https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html#create-service-role" target="_blank" rel="noopener noreferrer">', externalLinkIcon: this.externalLinkIcon, endLink: '</a>', }, @@ -342,10 +342,9 @@ export default { :empty-text="s__('ClusterIntegration|Kubernetes version not found')" @input="setKubernetesVersion({ kubernetesVersion: $event })" /> - <p class="form-text text-muted" v-html="roleDropdownHelpText"></p> </div> <div class="form-group"> - <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Role name') }}</label> + <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Service role') }}</label> <cluster-form-dropdown field-id="eks-role" field-name="eks-role" @@ -353,7 +352,7 @@ export default { :items="roles" :loading="isLoadingRoles" :loading-text="s__('ClusterIntegration|Loading IAM Roles')" - :placeholder="s__('ClusterIntergation|Select role name')" + :placeholder="s__('ClusterIntergation|Select service role')" :search-field-placeholder="s__('ClusterIntegration|Search IAM Roles')" :empty-text="s__('ClusterIntegration|No IAM Roles found')" :has-errors="Boolean(loadingRolesError)" diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue index 1dd4c468ae6..49a5d4657af 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue @@ -82,7 +82,7 @@ export default { }; </script> <template> - <form name="service-credentials-form" @submit.prevent="createRole({ roleArn, externalId })"> + <form name="service-credentials-form"> <h2>{{ s__('ClusterIntegration|Authenticate with Amazon Web Services') }}</h2> <p> {{ @@ -136,6 +136,7 @@ export default { :disabled="submitButtonDisabled" :loading="isCreatingRole" :label="submitButtonLabel" + @click.prevent="createRole({ roleArn, externalId })" /> </form> </template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js index 09fd560240d..8dc55506dc2 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js @@ -4,7 +4,7 @@ import * as getters from './getters'; import mutations from './mutations'; import state from './state'; -import clusterDropdownStore from './cluster_dropdown'; +import clusterDropdownStore from '~/create_cluster/store/cluster_dropdown'; import { fetchRoles, diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue new file mode 100644 index 00000000000..12b6070a79a --- /dev/null +++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue @@ -0,0 +1,53 @@ +<script> +import { createNamespacedHelpers, mapState, mapGetters, mapActions } from 'vuex'; + +import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue'; + +const { mapState: mapDropdownState } = createNamespacedHelpers('networks'); +const { mapActions: mapSubnetworkActions } = createNamespacedHelpers('subnetworks'); + +export default { + components: { + ClusterFormDropdown, + }, + props: { + fieldName: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['selectedNetwork']), + ...mapDropdownState(['items', 'isLoadingItems', 'loadingItemsError']), + ...mapGetters(['hasZone', 'projectId', 'region']), + }, + methods: { + ...mapActions(['setNetwork', 'setSubnetwork']), + ...mapSubnetworkActions({ fetchSubnetworks: 'fetchItems' }), + setNetworkAndFetchSubnetworks(network) { + const { projectId: project, region } = this; + + this.setSubnetwork(''); + this.setNetwork(network); + this.fetchSubnetworks({ project, region, network: network.selfLink }); + }, + }, +}; +</script> +<template> + <cluster-form-dropdown + :field-name="fieldName" + :value="selectedNetwork" + :items="items" + :disabled="!hasZone" + :loading="isLoadingItems" + :has-errors="Boolean(loadingItemsError)" + :loading-text="s__('ClusterIntegration|Loading networks')" + :placeholder="s__('ClusterIntergation|Select a network')" + :search-field-placeholder="s__('ClusterIntegration|Search networks')" + :empty-text="s__('ClusterIntegration|No networks found')" + :error-message="s__('ClusterIntegration|Could not load networks')" + :disabled-text="s__('ClusterIntegration|Select a zone to choose a network')" + @input="setNetworkAndFetchSubnetworks" + /> +</template> diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue new file mode 100644 index 00000000000..ec7889e2907 --- /dev/null +++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue @@ -0,0 +1,44 @@ +<script> +import { createNamespacedHelpers, mapState, mapGetters, mapActions } from 'vuex'; + +import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue'; + +const { mapState: mapDropdownState } = createNamespacedHelpers('subnetworks'); + +export default { + components: { + ClusterFormDropdown, + }, + props: { + fieldName: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['selectedSubnetwork']), + ...mapDropdownState(['items', 'isLoadingItems', 'loadingItemsError']), + ...mapGetters(['hasNetwork']), + }, + methods: { + ...mapActions(['setSubnetwork']), + }, +}; +</script> +<template> + <cluster-form-dropdown + :field-name="fieldName" + :value="selectedSubnetwork" + :items="items" + :disabled="!hasNetwork" + :loading="isLoadingItems" + :has-errors="Boolean(loadingItemsError)" + :loading-text="s__('ClusterIntegration|Loading subnetworks')" + :placeholder="s__('ClusterIntergation|Select a subnetwork')" + :search-field-placeholder="s__('ClusterIntegration|Search subnetworks')" + :empty-text="s__('ClusterIntegration|No subnetworks found')" + :error-message="s__('ClusterIntegration|Could not load subnetworks')" + :disabled-text="s__('ClusterIntegration|Select a network to choose a subnetwork')" + @input="setSubnetwork" + /> +</template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/actions.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/actions.js index 5d250b2e29e..5d250b2e29e 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/actions.js +++ b/app/assets/javascripts/create_cluster/store/cluster_dropdown/actions.js diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/getters.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/getters.js index e69de29bb2d..e69de29bb2d 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/getters.js +++ b/app/assets/javascripts/create_cluster/store/cluster_dropdown/getters.js diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/index.js index 0b19589215c..0b19589215c 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js +++ b/app/assets/javascripts/create_cluster/store/cluster_dropdown/index.js diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutation_types.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/mutation_types.js index 48959a73924..48959a73924 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutation_types.js +++ b/app/assets/javascripts/create_cluster/store/cluster_dropdown/mutation_types.js diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutations.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/mutations.js index d09689f1f6c..d09689f1f6c 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutations.js +++ b/app/assets/javascripts/create_cluster/store/cluster_dropdown/mutations.js diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/state.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/state.js index b949a24216e..b949a24216e 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/state.js +++ b/app/assets/javascripts/create_cluster/store/cluster_dropdown/state.js diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js index 18fb57c8b4f..304a0726597 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js @@ -24,7 +24,7 @@ const EMPTY_STAGE_TEXTS = { 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.', ), production: __( - 'The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.', + 'The total stage shows the time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.', ), }; diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue index 4d36a492c1c..c856e380c41 100644 --- a/app/assets/javascripts/deploy_keys/components/key.vue +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -115,7 +115,10 @@ export default { <div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Deploy key') }}</div> <div class="table-mobile-content qa-key"> <strong class="title qa-key-title"> {{ deployKey.title }} </strong> - <div class="fingerprint qa-key-fingerprint">{{ deployKey.fingerprint }}</div> + <div class="fingerprint" data-qa-selector="key_md5_fingerprint"> + {{ __('MD5') }}:{{ deployKey.fingerprint }} + </div> + <div class="fingerprint">{{ __('SHA256') }}:{{ deployKey.fingerprint_sha256 }}</div> </div> </div> <div class="table-section section-30 section-wrap"> diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 8ea443814e9..878b54f7d53 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -2,11 +2,11 @@ import { mapState, mapGetters, mapActions } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; import Mousetrap from 'mousetrap'; -import Icon from '~/vue_shared/components/icon.vue'; import { __ } from '~/locale'; import createFlash from '~/flash'; import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { isSingleViewStyle } from '~/helpers/diffs_helper'; import eventHub from '../../notes/event_hub'; import CompareVersions from './compare_versions.vue'; import DiffFile from './diff_file.vue'; @@ -27,7 +27,6 @@ import { export default { name: 'DiffsApp', components: { - Icon, CompareVersions, DiffFile, NoChanges, @@ -95,8 +94,8 @@ export default { parseInt(localStorage.getItem(TREE_LIST_WIDTH_STORAGE_KEY), 10) || INITIAL_TREE_WIDTH; return { - assignedDiscussions: false, treeWidth, + diffFilesLength: 0, }; }, computed: { @@ -114,6 +113,7 @@ export default { numVisibleFiles: state => state.diffs.size, plainDiffPath: state => state.diffs.plainDiffPath, emailPatchPath: state => state.diffs.emailPatchPath, + retrievingBatches: state => state.diffs.retrievingBatches, }), ...mapState('diffs', ['showTreeList', 'isLoading', 'startVersion']), ...mapGetters('diffs', ['isParallelView', 'currentDiffIndex']), @@ -144,12 +144,12 @@ export default { isLimitedContainer() { return !this.showTreeList && !this.isParallelView && !this.isFluidLayout; }, - shouldSetDiscussions() { - return this.isNotesFetched && !this.assignedDiscussions && !this.isLoading; - }, }, watch: { diffViewType() { + if (this.needsReload() || this.needsFirstLoad()) { + this.refetchDiffData(); + } this.adjustView(); }, shouldShow() { @@ -163,11 +163,6 @@ export default { }, isLoading: 'adjustView', showTreeList: 'adjustView', - shouldSetDiscussions(newVal) { - if (newVal) { - this.setDiscussions(); - } - }, }, mounted() { this.setBaseConfig({ @@ -192,10 +187,24 @@ export default { }, created() { this.adjustView(); - eventHub.$once('fetchedNotesData', this.setDiscussions); eventHub.$once('fetchDiffData', this.fetchData); eventHub.$on('refetchDiffData', this.refetchDiffData); this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES; + + this.unwatchDiscussions = this.$watch( + () => `${this.diffFiles.length}:${this.$store.state.notes.discussions.length}`, + () => this.setDiscussions(), + ); + + this.unwatchRetrievingBatches = this.$watch( + () => `${this.retrievingBatches}:${this.$store.state.notes.discussions.length}`, + () => { + if (!this.retrievingBatches && this.$store.state.notes.discussions.length) { + this.unwatchDiscussions(); + this.unwatchRetrievingBatches(); + } + }, + ); }, beforeDestroy() { eventHub.$off('fetchDiffData', this.fetchData); @@ -217,7 +226,6 @@ export default { 'toggleShowTreeList', ]), refetchDiffData() { - this.assignedDiscussions = false; this.fetchData(false); }, startDiffRendering() { @@ -228,10 +236,21 @@ export default { { timeout: 1000 }, ); }, + needsReload() { + return ( + this.glFeatures.singleMrDiffView && + this.diffFiles.length && + isSingleViewStyle(this.diffFiles[0]) + ); + }, + needsFirstLoad() { + return this.glFeatures.singleMrDiffView && !this.diffFiles.length; + }, fetchData(toggleTree = true) { if (this.glFeatures.diffsBatchLoad) { this.fetchDiffFilesMeta() - .then(() => { + .then(({ real_size }) => { + this.diffFilesLength = parseInt(real_size, 10); if (toggleTree) this.hideTreeListIfJustOneFile(); this.startDiffRendering(); @@ -241,19 +260,28 @@ export default { }); this.fetchDiffFilesBatch() + .then(() => { + // Guarantee the discussions are assigned after the batch finishes. + // Just watching the length of the discussions or the diff files + // isn't enough, because with split diff loading, neither will + // change when loading the other half of the diff files. + this.setDiscussions(); + }) .then(() => this.startDiffRendering()) .catch(() => { createFlash(__('Something went wrong on our end. Please try again!')); }); } else { this.fetchDiffFiles() - .then(() => { + .then(({ real_size }) => { + this.diffFilesLength = parseInt(real_size, 10); if (toggleTree) { this.hideTreeListIfJustOneFile(); } requestIdleCallback( () => { + this.setDiscussions(); this.startRenderDiffsQueue(); }, { timeout: 1000 }, @@ -269,17 +297,13 @@ export default { } }, setDiscussions() { - if (this.shouldSetDiscussions) { - this.assignedDiscussions = true; - - requestIdleCallback( - () => - this.assignDiscussionsToDiff() - .then(this.$nextTick) - .then(this.startTaskList), - { timeout: 1000 }, - ); - } + requestIdleCallback( + () => + this.assignDiscussionsToDiff() + .then(this.$nextTick) + .then(this.startTaskList), + { timeout: 1000 }, + ); }, adjustView() { if (this.shouldShow) { @@ -337,6 +361,7 @@ export default { :merge-request-diff="mergeRequestDiff" :target-branch="targetBranch" :is-limited-container="isLimitedContainer" + :diff-files-length="diffFilesLength" /> <hidden-files-warning @@ -349,7 +374,7 @@ export default { <div :data-can-create-note="getNoteableData.current_user.can_create_note" - class="files d-flex prepend-top-default" + class="files d-flex" > <div v-show="showTreeList" diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index 43a7703f611..cfffccd54eb 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -2,7 +2,6 @@ import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import Icon from '~/vue_shared/components/icon.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import CIIcon from '~/vue_shared/components/ci_icon.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; import initUserPopovers from '../../user_popovers'; @@ -25,7 +24,6 @@ export default { UserAvatarLink, Icon, ClipboardButton, - CIIcon, TimeAgoTooltip, CommitPipelineStatus, }, diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index 2e57a47f2f7..63ce43a193d 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -1,5 +1,4 @@ <script> -/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { mapActions, mapGetters, mapState } from 'vuex'; import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; @@ -42,9 +41,13 @@ export default { required: false, default: false, }, + diffFilesLength: { + type: Number, + required: true, + }, }, computed: { - ...mapGetters('diffs', ['hasCollapsedFile', 'diffFilesLength']), + ...mapGetters('diffs', ['hasCollapsedFile']), ...mapState('diffs', [ 'commit', 'showTreeList', @@ -59,9 +62,6 @@ export default { showDropdowns() { return !this.commit && this.mergeRequestDiffs.length; }, - fileTreeIcon() { - return this.showTreeList ? 'collapse-left' : 'expand-left'; - }, toggleFileBrowserTitle() { return this.showTreeList ? __('Hide file browser') : __('Show file browser'); }, @@ -87,7 +87,7 @@ export default { </script> <template> - <div class="mr-version-controls border-top border-bottom"> + <div class="mr-version-controls border-top"> <div class="mr-version-menus-container content-block" :class="{ @@ -104,17 +104,17 @@ export default { :title="toggleFileBrowserTitle" @click="toggleShowTreeList" > - <icon :name="fileTreeIcon" /> + <icon name="file-tree" /> </button> <div v-if="showDropdowns" class="d-flex align-items-center compare-versions-container"> - Changes between + {{ __('Compare') }} <compare-versions-dropdown :other-versions="mergeRequestDiffs" :merge-request-version="mergeRequestDiff" :show-commit-count="true" class="mr-version-dropdown" /> - and + {{ __('and') }} <compare-versions-dropdown :other-versions="comparableDiffs" :base-version-path="baseVersionPath" diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 0dbff4ffcec..f5051748f10 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -4,6 +4,7 @@ import _ from 'underscore'; import { GlLoadingIcon } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import createFlash from '~/flash'; +import { hasDiff } from '~/helpers/diffs_helper'; import eventHub from '../../notes/event_hub'; import DiffFileHeader from './diff_file_header.vue'; import DiffContent from './diff_content.vue'; @@ -55,12 +56,7 @@ export default { return this.isLoadingCollapsedDiff || (!this.file.renderIt && !this.isCollapsed); }, hasDiff() { - return ( - (this.file.highlighted_diff_lines && - this.file.parallel_diff_lines && - this.file.parallel_diff_lines.length > 0) || - !this.file.blob.readable_text - ); + return hasDiff(this.file); }, isFileTooLarge() { return this.file.viewer.error === diffViewerErrors.too_large; diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 91d374eafc0..e78bea789c3 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -1,7 +1,7 @@ <script> import _ from 'underscore'; import { mapActions, mapGetters } from 'vuex'; -import { GlButton, GlTooltipDirective, GlTooltip, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui'; import { polyfillSticky } from '~/lib/utils/sticky'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import Icon from '~/vue_shared/components/icon.vue'; @@ -15,7 +15,6 @@ import { scrollToElement } from '~/lib/utils/common_utils'; export default { components: { - GlTooltip, GlLoadingIcon, GlButton, ClipboardButton, @@ -124,6 +123,20 @@ export default { } return s__('MRDiff|Show full file'); }, + changedFile() { + const { + new_path: changed, + deleted_file: deleted, + new_file: tempFile, + ...diffFile + } = this.diffFile; + return { + ...diffFile, + changed: Boolean(changed), + deleted, + tempFile, + }; + }, }, mounted() { polyfillSticky(this.$refs.header); @@ -222,7 +235,7 @@ export default { <div v-if="!diffFile.submodule && addMergeRequestButtons" - class="file-actions d-none d-sm-block" + class="file-actions d-none d-sm-flex align-items-center" > <diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" /> <div class="btn-group" role="group"> diff --git a/app/assets/javascripts/diffs/components/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue index 2e5855380af..1fa1fda7bd7 100644 --- a/app/assets/javascripts/diffs/components/diff_stats.vue +++ b/app/assets/javascripts/diffs/components/diff_stats.vue @@ -1,9 +1,7 @@ <script> -import Icon from '~/vue_shared/components/icon.vue'; import { n__ } from '~/locale'; export default { - components: { Icon }, props: { addedLines: { type: Number, @@ -21,7 +19,7 @@ export default { }, computed: { filesText() { - return n__('File', 'Files', this.diffFilesLength); + return n__('file', 'files', this.diffFilesLength); }, isCompareVersionsHeader() { return Boolean(this.diffFilesLength); @@ -39,14 +37,21 @@ export default { }" > <div v-if="diffFilesLength !== null" class="diff-stats-group"> - <icon name="doc-code" class="diff-stats-icon text-secondary" /> - <strong>{{ diffFilesLength }} {{ filesText }}</strong> + <span class="text-secondary bold">{{ diffFilesLength }} {{ filesText }}</span> </div> - <div class="diff-stats-group cgreen"> - <icon name="file-addition" class="diff-stats-icon" /> <strong>{{ addedLines }}</strong> + <div + class="diff-stats-group cgreen d-flex align-items-center" + :class="{ bold: isCompareVersionsHeader }" + > + <span>+</span> + <span class="js-file-addition-line">{{ addedLines }}</span> </div> - <div class="diff-stats-group cred"> - <icon name="file-deletion" class="diff-stats-icon" /> <strong>{{ removedLines }}</strong> + <div + class="diff-stats-group cred d-flex align-items-center" + :class="{ bold: isCompareVersionsHeader }" + > + <span>-</span> + <span class="js-file-deletion-line">{{ removedLines }}</span> </div> </div> </template> diff --git a/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue b/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue index 6e732727f42..071a988d789 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue @@ -1,11 +1,9 @@ <script> -import Icon from '~/vue_shared/components/icon.vue'; import DiffExpansionCell from './diff_expansion_cell.vue'; import { MATCH_LINE_TYPE } from '../constants'; export default { components: { - Icon, DiffExpansionCell, }, props: { diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index 30be2e68e76..7956d05b4f1 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -4,7 +4,6 @@ import { GlTooltipDirective } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import FileRow from '~/vue_shared/components/file_row.vue'; -import FileRowStats from './file_row_stats.vue'; export default { directives: { @@ -48,9 +47,6 @@ export default { return acc; }, []); }, - fileRowExtraComponent() { - return this.hideFileStats ? null : FileRowStats; - }, }, methods: { ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile']), @@ -58,8 +54,8 @@ export default { this.search = ''; }, }, - searchPlaceholder: sprintf(s__('MergeRequest|Filter files or search with %{modifier_key}+p'), { - modifier_key: /Mac/i.test(navigator.userAgent) ? 'cmd' : 'ctrl', + searchPlaceholder: sprintf(s__('MergeRequest|Search files (%{modifier_key}P)'), { + modifier_key: /Mac/i.test(navigator.userAgent) ? 'ā' : 'Ctrl+', }), }; </script> @@ -97,7 +93,6 @@ export default { :file="file" :level="0" :hide-extra-on-tree="true" - :extra-component="fileRowExtraComponent" :show-changed-icon="true" @toggleTreeOpen="toggleTreeOpen" @clickFile="scrollToFile" diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 992b45c97ac..b920e041135 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -64,6 +64,11 @@ export const fetchDiffFiles = ({ state, commit }) => { const urlParams = { w: state.showWhitespace ? '0' : '1', }; + let returnData; + + if (state.useSingleDiffStyle) { + urlParams.view = state.diffViewType; + } commit(types.SET_LOADING, true); @@ -83,26 +88,42 @@ export const fetchDiffFiles = ({ state, commit }) => { worker.postMessage(state.diffFiles); + returnData = res.data; return Vue.nextTick(); }) - .then(handleLocationHash) + .then(() => { + handleLocationHash(); + return returnData; + }) .catch(() => worker.terminate()); }; export const fetchDiffFilesBatch = ({ commit, state }) => { + const urlParams = { + per_page: DIFFS_PER_PAGE, + w: state.showWhitespace ? '0' : '1', + }; + + if (state.useSingleDiffStyle) { + urlParams.view = state.diffViewType; + } + commit(types.SET_BATCH_LOADING, true); + commit(types.SET_RETRIEVING_BATCHES, true); const getBatch = page => axios .get(state.endpointBatch, { - params: { page, per_page: DIFFS_PER_PAGE, w: state.showWhitespace ? '0' : '1' }, + params: { ...urlParams, page }, }) .then(({ data: { pagination, diff_files } }) => { commit(types.SET_DIFF_DATA_BATCH, { diff_files }); commit(types.SET_BATCH_LOADING, false); + if (!pagination.next_page) commit(types.SET_RETRIEVING_BATCHES, false); return pagination.next_page; }) - .then(nextPage => nextPage && getBatch(nextPage)); + .then(nextPage => nextPage && getBatch(nextPage)) + .catch(() => commit(types.SET_RETRIEVING_BATCHES, false)); return getBatch() .then(handleLocationHash) @@ -131,6 +152,7 @@ export const fetchDiffFilesMeta = ({ commit, state }) => { prepareDiffData(data); worker.postMessage(data.diff_files); + return data; }) .catch(() => worker.terminate()); }; @@ -147,7 +169,10 @@ export const assignDiscussionsToDiff = ( { commit, state, rootState }, discussions = rootState.notes.discussions, ) => { - const diffPositionByLineCode = getDiffPositionByLineCode(state.diffFiles); + const diffPositionByLineCode = getDiffPositionByLineCode( + state.diffFiles, + state.useSingleDiffStyle, + ); const hash = getLocationHash(); discussions @@ -336,24 +361,23 @@ 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 => { + const lineCodesWithDiscussions = new Set(); + const { parallel_diff_lines: parallelLines, highlighted_diff_lines: inlineLines } = diff; + const allLines = inlineLines.concat( + parallelLines.map(line => line.left), + parallelLines.map(line => line.right), + ); + const lineHasDiscussion = line => Boolean(line?.discussions.length); + const registerDiscussionLine = line => lineCodesWithDiscussions.add(line.line_code); + + allLines.filter(lineHasDiscussion).forEach(registerDiscussionLine); + + if (lineCodesWithDiscussions.size) { + Array.from(lineCodesWithDiscussions).forEach(lineCode => { commit(types.TOGGLE_LINE_DISCUSSIONS, { fileHash: diff.file_hash, - lineCode: line.line_code, expanded: !discussionWrappersExpanded, + lineCode, }); }); } diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index bc27e263bff..c4737090a70 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -95,8 +95,6 @@ export const allBlobs = (state, getters) => return acc; }, []); -export const diffFilesLength = state => state.diffFiles.length; - export const getCommentFormForDiffFile = state => fileHash => state.commentForms.find(form => form.fileHash === fileHash); diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index 7366c50752c..011cd24500a 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -9,6 +9,7 @@ const defaultViewType = INLINE_DIFF_VIEW_TYPE; export default () => ({ isLoading: true, isBatchLoading: false, + retrievingBatches: false, addedLines: null, removedLines: null, endpoint: '', diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index 5a90d78b2bc..2097c8d3655 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -1,6 +1,7 @@ export const SET_BASE_CONFIG = 'SET_BASE_CONFIG'; export const SET_LOADING = 'SET_LOADING'; export const SET_BATCH_LOADING = 'SET_BATCH_LOADING'; +export const SET_RETRIEVING_BATCHES = 'SET_RETRIEVING_BATCHES'; export const SET_DIFF_DATA = 'SET_DIFF_DATA'; export const SET_DIFF_DATA_BATCH = 'SET_DIFF_DATA_BATCH'; export const SET_DIFF_VIEW_TYPE = 'SET_DIFF_VIEW_TYPE'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 859f43b3b6d..1505be1a0b2 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -40,27 +40,33 @@ export default { Object.assign(state, { isBatchLoading }); }, + [types.SET_RETRIEVING_BATCHES](state, retrievingBatches) { + Object.assign(state, { retrievingBatches }); + }, + [types.SET_DIFF_DATA](state, data) { + let files = state.diffFiles; + if ( - !( - gon && - gon.features && - gon.features.diffsBatchLoad && - window.location.search.indexOf('diff_id') === -1 - ) + !(gon?.features?.diffsBatchLoad && window.location.search.indexOf('diff_id') === -1) && + data.diff_files ) { - prepareDiffData(data); + files = prepareDiffData(data, files); } Object.assign(state, { ...convertObjectPropsToCamelCase(data), + diffFiles: files, }); }, [types.SET_DIFF_DATA_BATCH](state, data) { - prepareDiffData(data); + const files = prepareDiffData(data, state.diffFiles); - state.diffFiles.push(...data.diff_files); + Object.assign(state, { + ...convertObjectPropsToCamelCase(data), + diffFiles: files, + }); }, [types.RENDER_FILE](state, file) { @@ -84,11 +90,11 @@ export default { if (!diffFile) return; - if (diffFile.highlighted_diff_lines) { + if (diffFile.highlighted_diff_lines.length) { diffFile.highlighted_diff_lines.find(l => l.line_code === lineCode).hasForm = hasForm; } - if (diffFile.parallel_diff_lines) { + if (diffFile.parallel_diff_lines.length) { const line = diffFile.parallel_diff_lines.find(l => { const { left, right } = l; @@ -149,13 +155,13 @@ export default { }, [types.EXPAND_ALL_FILES](state) { - state.diffFiles = state.diffFiles.map(file => ({ - ...file, - viewer: { - ...file.viewer, - collapsed: false, - }, - })); + state.diffFiles.forEach(file => { + Object.assign(file, { + viewer: Object.assign(file.viewer, { + collapsed: false, + }), + }); + }); }, [types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode, hash }) { @@ -173,16 +179,19 @@ export default { const mapDiscussions = (line, extraCheck = () => true) => ({ ...line, discussions: extraCheck() - ? line.discussions + ? line.discussions && + line.discussions .filter(() => !line.discussions.some(({ id }) => discussion.id === id)) .concat(lineCheck(line) ? discussion : line.discussions) : [], }); const setDiscussionsExpanded = line => { - const isLineNoteTargeted = line.discussions.some( - disc => disc.notes && disc.notes.find(note => hash === `note_${note.id}`), - ); + const isLineNoteTargeted = + line.discussions && + line.discussions.some( + disc => disc.notes && disc.notes.find(note => hash === `note_${note.id}`), + ); return { ...line, @@ -193,29 +202,29 @@ export default { }; }; - 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 => - setDiscussionsExpanded(lineCheck(line) ? mapDiscussions(line) : line), - ); + state.diffFiles.forEach(file => { + if (file.file_hash === fileHash) { + if (file.highlighted_diff_lines.length) { + file.highlighted_diff_lines.forEach(line => { + Object.assign( + line, + setDiscussionsExpanded(lineCheck(line) ? mapDiscussions(line) : line), + ); + }); } - if (file.parallel_diff_lines) { - file.parallel_diff_lines = file.parallel_diff_lines.map(line => { + if (file.parallel_diff_lines.length) { + file.parallel_diff_lines.forEach(line => { const left = line.left && lineCheck(line.left); const right = line.right && lineCheck(line.right); if (left || right) { - return { - ...line, + Object.assign(line, { left: line.left ? setDiscussionsExpanded(mapDiscussions(line.left)) : null, right: line.right ? setDiscussionsExpanded(mapDiscussions(line.right, () => !left)) : null, - }; + }); } return line; @@ -223,15 +232,15 @@ export default { } if (!file.parallel_diff_lines || !file.highlighted_diff_lines) { - file.discussions = (file.discussions || []) + const newDiscussions = (file.discussions || []) .filter(d => d.id !== discussion.id) .concat(discussion); - } - return file; + Object.assign(file, { + discussions: newDiscussions, + }); + } } - - return diffFile; }); }, @@ -255,9 +264,9 @@ 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 }), - ); + updateLineInFile(selectedFile, lineCode, line => { + Object.assign(line, { discussionsExpanded: expanded }); + }); }, [types.TOGGLE_FOLDER_OPEN](state, path) { diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 281a0de1fc2..b379f1fabef 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -185,6 +185,7 @@ export function addContextLines(options) { * Trims the first char of the `richText` property when it's either a space or a diff symbol. * @param {Object} line * @returns {Object} + * @deprecated */ export function trimFirstCharOfLineContent(line = {}) { // eslint-disable-next-line no-param-reassign @@ -212,79 +213,171 @@ function getLineCode({ left, right }, index) { return index; } -// This prepares and optimizes the incoming diff data from the server -// by setting up incremental rendering and removing unneeded data -export function prepareDiffData(diffData) { - const filesLength = diffData.diff_files.length; - let showingLines = 0; - for (let i = 0; i < filesLength; i += 1) { - const file = diffData.diff_files[i]; - - if (file.parallel_diff_lines) { - const linesLength = file.parallel_diff_lines.length; - for (let u = 0; u < linesLength; u += 1) { - const line = file.parallel_diff_lines[u]; - - line.line_code = getLineCode(line, u); - if (line.left) { - line.left = trimFirstCharOfLineContent(line.left); - line.left.discussions = []; - line.left.hasForm = false; - } - if (line.right) { - line.right = trimFirstCharOfLineContent(line.right); - line.right.discussions = []; - line.right.hasForm = false; - } +function diffFileUniqueId(file) { + return `${file.content_sha}-${file.file_hash}`; +} + +function combineDiffFilesWithPriorFiles(files, prior = []) { + files.forEach(file => { + const id = diffFileUniqueId(file); + const oldMatch = prior.find(oldFile => diffFileUniqueId(oldFile) === id); + + if (oldMatch) { + const missingInline = !file.highlighted_diff_lines; + const missingParallel = !file.parallel_diff_lines; + + if (missingInline) { + Object.assign(file, { + highlighted_diff_lines: oldMatch.highlighted_diff_lines, + }); } - } - if (file.highlighted_diff_lines) { - const linesLength = file.highlighted_diff_lines.length; - for (let u = 0; u < linesLength; u += 1) { - const line = file.highlighted_diff_lines[u]; - Object.assign(line, { - ...trimFirstCharOfLineContent(line), - discussions: [], - hasForm: false, + if (missingParallel) { + Object.assign(file, { + parallel_diff_lines: oldMatch.parallel_diff_lines, }); } - showingLines += file.parallel_diff_lines.length; + } + }); + + return files; +} + +function ensureBasicDiffFileLines(file) { + const missingInline = !file.highlighted_diff_lines; + const missingParallel = !file.parallel_diff_lines; + + Object.assign(file, { + highlighted_diff_lines: missingInline ? [] : file.highlighted_diff_lines, + parallel_diff_lines: missingParallel ? [] : file.parallel_diff_lines, + }); + + return file; +} + +function cleanRichText(text) { + return text ? text.replace(/^[+ -]/, '') : undefined; +} + +function prepareLine(line) { + return Object.assign(line, { + rich_text: cleanRichText(line.rich_text), + discussionsExpanded: true, + discussions: [], + hasForm: false, + text: undefined, + }); +} + +function prepareDiffFileLines(file) { + const inlineLines = file.highlighted_diff_lines; + const parallelLines = file.parallel_diff_lines; + let parallelLinesCount = 0; + + inlineLines.forEach(prepareLine); + + parallelLines.forEach((line, index) => { + Object.assign(line, { line_code: getLineCode(line, index) }); + + if (line.left) { + parallelLinesCount += 1; + prepareLine(line.left); } - const name = (file.viewer && file.viewer.name) || diffViewerModes.text; + if (line.right) { + parallelLinesCount += 1; + prepareLine(line.right); + } Object.assign(file, { - renderIt: showingLines < LINES_TO_BE_RENDERED_DIRECTLY, - collapsed: name === diffViewerModes.text && showingLines > MAX_LINES_TO_BE_RENDERED, - isShowingFullFile: false, - isLoadingFullFile: false, - discussions: [], - renderingLines: false, + inlineLinesCount: inlineLines.length, + parallelLinesCount, }); - } + }); + + return file; } -export function getDiffPositionByLineCode(diffFiles) { - return diffFiles.reduce((acc, diffFile) => { - // We can only use highlightedDiffLines to create the map of diff lines because - // highlightedDiffLines will also include every parallel diff line in it. - if (diffFile.highlighted_diff_lines) { +function getVisibleDiffLines(file) { + return Math.max(file.inlineLinesCount, file.parallelLinesCount); +} + +function finalizeDiffFile(file) { + const name = (file.viewer && file.viewer.name) || diffViewerModes.text; + const lines = getVisibleDiffLines(file); + + Object.assign(file, { + renderIt: lines < LINES_TO_BE_RENDERED_DIRECTLY, + collapsed: name === diffViewerModes.text && lines > MAX_LINES_TO_BE_RENDERED, + isShowingFullFile: false, + isLoadingFullFile: false, + discussions: [], + renderingLines: false, + }); + + return file; +} + +export function prepareDiffData(diffData, priorFiles) { + return combineDiffFilesWithPriorFiles(diffData.diff_files, priorFiles) + .map(ensureBasicDiffFileLines) + .map(prepareDiffFileLines) + .map(finalizeDiffFile); +} + +export function getDiffPositionByLineCode(diffFiles, useSingleDiffStyle) { + let lines = []; + const hasInlineDiffs = diffFiles.some(file => file.highlighted_diff_lines.length > 0); + + if (!useSingleDiffStyle || hasInlineDiffs) { + // In either of these cases, we can use `highlighted_diff_lines` because + // that will include all of the parallel diff lines, too + + lines = diffFiles.reduce((acc, diffFile) => { diffFile.highlighted_diff_lines.forEach(line => { - if (line.line_code) { - acc[line.line_code] = { - base_sha: diffFile.diff_refs.base_sha, - head_sha: diffFile.diff_refs.head_sha, - start_sha: diffFile.diff_refs.start_sha, - new_path: diffFile.new_path, - old_path: diffFile.old_path, - old_line: line.old_line, - new_line: line.new_line, - line_code: line.line_code, - position_type: 'text', - }; + acc.push({ file: diffFile, line }); + }); + + return acc; + }, []); + } else { + // If we're in single diff view mode and the inline lines haven't been + // loaded yet, we need to parse the parallel lines + + lines = diffFiles.reduce((acc, diffFile) => { + diffFile.parallel_diff_lines.forEach(pair => { + // It's possible for a parallel line to have an opposite line that doesn't exist + // For example: *deleted* lines will have `null` right lines, while + // *added* lines will have `null` left lines. + // So we have to check each line before we push it onto the array so we're not + // pushing null line diffs + + if (pair.left) { + acc.push({ file: diffFile, line: pair.left }); + } + + if (pair.right) { + acc.push({ file: diffFile, line: pair.right }); } }); + + return acc; + }, []); + } + + return lines.reduce((acc, { file, line }) => { + if (line.line_code) { + acc[line.line_code] = { + base_sha: file.diff_refs.base_sha, + head_sha: file.diff_refs.head_sha, + start_sha: file.diff_refs.start_sha, + new_path: file.new_path, + old_path: file.old_path, + old_line: line.old_line, + new_line: line.new_line, + line_code: line.line_code, + position_type: 'text', + }; } return acc; @@ -462,47 +555,47 @@ 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'; +function getLinesFromFileByLineCode(file, lineCode) { + const parallelLines = file.parallel_diff_lines; + const inlineLines = file.highlighted_diff_lines; + const matchesCode = line => line.line_code === lineCode; - updateFn(targetLine[side]); - } - } - if (selectedFile.highlighted_diff_lines) { - const targetInlineLine = selectedFile.highlighted_diff_lines.find( - line => line.line_code === lineCode, - ); + return [ + ...parallelLines.reduce((acc, line) => { + if (line.left) { + acc.push(line.left); + } - if (targetInlineLine) { - updateFn(targetInlineLine); - } - } + if (line.right) { + acc.push(line.right); + } + + return acc; + }, []), + ...inlineLines, + ].filter(matchesCode); +} + +export const updateLineInFile = (selectedFile, lineCode, updateFn) => { + getLinesFromFileByLineCode(selectedFile, lineCode).forEach(updateFn); }; 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.highlighted_diff_lines.forEach(line => { - if (line.discussions.length) { - discussionsExpandedArray.push(line.discussionsExpanded); - } - }); - } - return discussionsExpandedArray.every(el => el); + let discussionsExpanded = true; + const changeExpandedResult = line => { + if (line && line.discussions.length) { + discussionsExpanded = discussionsExpanded && line.discussionsExpanded; + } + }; + + diff.parallel_diff_lines.forEach(line => { + changeExpandedResult(line.left); + changeExpandedResult(line.right); + }); + + diff.highlighted_diff_lines.forEach(line => { + changeExpandedResult(line); + }); + + return discussionsExpanded; }; diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js index ccb3d56ed8c..31d32fb5060 100644 --- a/app/assets/javascripts/droplab/drop_down.js +++ b/app/assets/javascripts/droplab/drop_down.js @@ -101,6 +101,11 @@ class DropDown { render(data) { const children = data ? data.map(this.renderChildren.bind(this)) : []; + + if (this.list.querySelector('.filter-dropdown-loading')) { + return; + } + const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list; renderableList.innerHTML = children.join(''); diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 62b390a46d7..86590865892 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import Dropzone from 'dropzone'; import _ from 'underscore'; import './behaviors/preview_markdown'; +import PasteMarkdownTable from './behaviors/markdown/paste_markdown_table'; import csrf from './lib/utils/csrf'; import axios from './lib/utils/axios_utils'; import { n__, __ } from '~/locale'; @@ -173,14 +174,25 @@ export default function dropzoneInput(form) { // eslint-disable-next-line consistent-return handlePaste = event => { const pasteEvent = event.originalEvent; - if (pasteEvent.clipboardData && pasteEvent.clipboardData.items) { - const image = isImage(pasteEvent); - if (image) { + const { clipboardData } = pasteEvent; + if (clipboardData && clipboardData.items) { + const converter = new PasteMarkdownTable(clipboardData); + // Apple Numbers copies a table as an image, HTML, and text, so + // we need to check for the presence of a table first. + if (converter.isTable()) { event.preventDefault(); - const filename = getFilename(pasteEvent) || 'image.png'; - const text = `{{${filename}}}`; + const text = converter.convertToTableMarkdown(); pasteText(text); - return uploadFile(image.getAsFile(), filename); + } else { + const image = isImage(pasteEvent); + + if (image) { + event.preventDefault(); + const filename = getFilename(pasteEvent) || 'image.png'; + const text = `{{${filename}}}`; + pasteText(text); + return uploadFile(image.getAsFile(), filename); + } } } }; diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js index 3c650397a19..b973316b3b9 100644 --- a/app/assets/javascripts/due_date_select.js +++ b/app/assets/javascripts/due_date_select.js @@ -116,11 +116,13 @@ class DueDateSelect { } updateIssueBoardIssue() { + // eslint-disable-next-line no-jquery/no-fade this.$loading.fadeIn(); this.$dropdown.trigger('loading.gl.dropdown'); this.$selectbox.hide(); this.$value.css('display', ''); const fadeOutLoader = () => { + // eslint-disable-next-line no-jquery/no-fade this.$loading.fadeOut(); }; @@ -135,6 +137,7 @@ class DueDateSelect { const hasDueDate = this.displayedDate !== __('None'); const displayedDateStyle = hasDueDate ? 'bold' : 'no-value'; + // eslint-disable-next-line no-jquery/no-fade this.$loading.removeClass('hidden').fadeIn(); if (isDropdown) { @@ -158,6 +161,7 @@ class DueDateSelect { } this.$sidebarCollapsedValue.attr('data-original-title', tooltipText); + // eslint-disable-next-line no-jquery/no-fade return this.$loading.fadeOut(); }); } diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 428dfe5fcf7..3096ccad0aa 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -1,22 +1,23 @@ <script> /* eslint-disable @gitlab/vue-i18n/no-bare-strings */ -import { format } from 'timeago.js'; import _ from 'underscore'; import { GlTooltipDirective } from '@gitlab/ui'; -import environmentItemMixin from 'ee_else_ce/environments/mixins/environment_item_mixin'; +import { __, sprintf } from '~/locale'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import CommitComponent from '~/vue_shared/components/commit.vue'; import Icon from '~/vue_shared/components/icon.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; -import { __, sprintf } from '~/locale'; +import environmentItemMixin from 'ee_else_ce/environments/mixins/environment_item_mixin'; +import eventHub from '../event_hub'; import ActionsComponent from './environment_actions.vue'; import ExternalUrlComponent from './environment_external_url.vue'; -import StopComponent from './environment_stop.vue'; +import MonitoringButtonComponent from './environment_monitoring.vue'; +import PinComponent from './environment_pin.vue'; import RollbackComponent from './environment_rollback.vue'; +import StopComponent from './environment_stop.vue'; import TerminalButtonComponent from './environment_terminal_button.vue'; -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'; /** * Environment Item Component @@ -26,21 +27,22 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; export default { components: { - CommitComponent, - Icon, ActionsComponent, + CommitComponent, ExternalUrlComponent, - StopComponent, + Icon, + MonitoringButtonComponent, + PinComponent, RollbackComponent, + StopComponent, TerminalButtonComponent, - MonitoringButtonComponent, TooltipOnTruncate, UserAvatarLink, }, directives: { GlTooltip: GlTooltipDirective, }, - mixins: [environmentItemMixin], + mixins: [environmentItemMixin, timeagoMixin], props: { canReadEnvironment: { @@ -52,7 +54,12 @@ export default { model: { type: Object, required: true, - default: () => ({}), + }, + + shouldShowAutoStopDate: { + type: Boolean, + required: false, + default: false, }, tableData: { @@ -77,6 +84,16 @@ export default { }, /** + * Checkes whether the row displayed is a folder. + * + * @returns {Boolean} + */ + + isFolder() { + return this.model.isFolder; + }, + + /** * Checkes whether the environment is protected. * (`is_protected` currently only set in EE) * @@ -112,24 +129,64 @@ export default { }, /** - * Verifies if the date to be shown is present. + * Verifies if the autostop date is present. + * + * @returns {Boolean} + */ + canShowAutoStopDate() { + if (!this.model.auto_stop_at) { + return false; + } + + const autoStopDate = new Date(this.model.auto_stop_at); + const now = new Date(); + + return now < autoStopDate; + }, + + /** + * Human readable deployment date. + * + * @returns {String} + */ + autoStopDate() { + if (this.canShowAutoStopDate) { + return { + formatted: this.timeFormatted(this.model.auto_stop_at), + tooltip: this.tooltipTitle(this.model.auto_stop_at), + }; + } + return { + formatted: '', + tooltip: '', + }; + }, + + /** + * Verifies if the deployment date is present. * * @returns {Boolean|Undefined} */ - canShowDate() { + canShowDeploymentDate() { return this.model && this.model.last_deployment && this.model.last_deployment.deployed_at; }, /** - * Human readable date. + * Human readable deployment date. * * @returns {String} */ deployedDate() { - if (this.canShowDate) { - return format(this.model.last_deployment.deployed_at); + if (this.canShowDeploymentDate) { + return { + formatted: this.timeFormatted(this.model.last_deployment.deployed_at), + tooltip: this.tooltipTitle(this.model.last_deployment.deployed_at), + }; } - return ''; + return { + formatted: '', + tooltip: '', + }; }, actions() { @@ -345,6 +402,15 @@ export default { }, /** + * Checkes whether to display no deployment text. + * + * @returns {Boolean} + */ + showNoDeployments() { + return !this.hasLastDeploymentKey && !this.isFolder; + }, + + /** * Verifies if the build name column should be rendered by verifing * if all the information needed is present * and if the environment is not a folder. @@ -353,7 +419,7 @@ export default { */ shouldRenderBuildName() { return ( - !this.model.isFolder && + !this.isFolder && !_.isEmpty(this.model.last_deployment) && !_.isEmpty(this.model.last_deployment.deployable) ); @@ -383,11 +449,7 @@ export default { * @return {String} */ externalURL() { - if (this.model && this.model.external_url) { - return this.model.external_url; - } - - return ''; + return this.model.external_url || ''; }, /** @@ -399,26 +461,22 @@ export default { */ shouldRenderDeploymentID() { return ( - !this.model.isFolder && + !this.isFolder && !_.isEmpty(this.model.last_deployment) && this.model.last_deployment.iid !== undefined ); }, environmentPath() { - if (this.model && this.model.environment_path) { - return this.model.environment_path; - } - - return ''; + return this.model.environment_path || ''; }, monitoringUrl() { - if (this.model && this.model.metrics_path) { - return this.model.metrics_path; - } + return this.model.metrics_path || ''; + }, - return ''; + autoStopUrl() { + return this.model.cancel_auto_stop_path || ''; }, displayEnvironmentActions() { @@ -447,7 +505,7 @@ export default { <div :class="{ 'js-child-row environment-child-row': model.isChildren, - 'folder-row': model.isFolder, + 'folder-row': isFolder, }" class="gl-responsive-table-row" role="row" @@ -457,7 +515,7 @@ export default { :class="tableData.name.spacing" role="gridcell" > - <div v-if="!model.isFolder" class="table-mobile-header" role="rowheader"> + <div v-if="!isFolder" class="table-mobile-header" role="rowheader"> {{ tableData.name.title }} </div> @@ -466,7 +524,7 @@ export default { </span> <span - v-if="!model.isFolder" + v-if="!isFolder" v-gl-tooltip :title="model.name" class="environment-name table-mobile-content" @@ -506,7 +564,7 @@ export default { {{ deploymentInternalId }} </span> - <span v-if="!model.isFolder && deploymentHasUser" class="text-break-word"> + <span v-if="!isFolder && deploymentHasUser" class="text-break-word"> by <user-avatar-link :link-href="deploymentUser.web_url" @@ -516,6 +574,10 @@ export default { class="js-deploy-user-container float-none" /> </span> + + <div v-if="showNoDeployments" class="commit-title table-mobile-content"> + {{ s__('Environments|No deployments yet') }} + </div> </div> <div @@ -536,14 +598,8 @@ export default { </a> </div> - <div - v-if="!model.isFolder" - class="table-section" - :class="tableData.commit.spacing" - role="gridcell" - > + <div v-if="!isFolder" class="table-section" :class="tableData.commit.spacing" role="gridcell"> <div role="rowheader" class="table-mobile-header">{{ tableData.commit.title }}</div> - <div v-if="hasLastDeploymentKey" class="js-commit-component table-mobile-content"> <commit-component :tag="commitTag" @@ -554,31 +610,51 @@ export default { :author="commitAuthor" /> </div> - <div v-if="!hasLastDeploymentKey" class="commit-title table-mobile-content"> - {{ s__('Environments|No deployments yet') }} - </div> + </div> + + <div v-if="!isFolder" class="table-section" :class="tableData.date.spacing" role="gridcell"> + <div role="rowheader" class="table-mobile-header">{{ tableData.date.title }}</div> + <span + v-if="canShowDeploymentDate" + v-gl-tooltip + :title="deployedDate.tooltip" + class="environment-created-date-timeago table-mobile-content flex-truncate-parent" + > + <span class="flex-truncate-child"> + {{ deployedDate.formatted }} + </span> + </span> </div> <div - v-if="!model.isFolder" + v-if="!isFolder && shouldShowAutoStopDate" class="table-section" - :class="tableData.date.spacing" + :class="tableData.autoStop.spacing" role="gridcell" > - <div role="rowheader" class="table-mobile-header">{{ tableData.date.title }}</div> - - <span v-if="canShowDate" class="environment-created-date-timeago table-mobile-content"> - {{ deployedDate }} + <div role="rowheader" class="table-mobile-header">{{ tableData.autoStop.title }}</div> + <span + v-if="canShowAutoStopDate" + v-gl-tooltip + :title="autoStopDate.tooltip" + class="table-mobile-content flex-truncate-parent" + > + <span class="flex-truncate-child js-auto-stop">{{ autoStopDate.formatted }}</span> </span> </div> <div - v-if="!model.isFolder && displayEnvironmentActions" + v-if="!isFolder && displayEnvironmentActions" class="table-section table-button-footer" :class="tableData.actions.spacing" role="gridcell" > <div class="btn-group table-action-buttons" role="group"> + <pin-component + v-if="canShowAutoStopDate && shouldShowAutoStopDate" + :auto-stop-url="autoStopUrl" + /> + <external-url-component v-if="externalURL && canReadEnvironment" :external-url="externalURL" diff --git a/app/assets/javascripts/environments/components/environment_pin.vue b/app/assets/javascripts/environments/components/environment_pin.vue new file mode 100644 index 00000000000..7908928a7ac --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_pin.vue @@ -0,0 +1,37 @@ +<script> +/** + * Renders a prevent auto-stop button. + * Used in environments table. + */ +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import { __ } from '~/locale'; +import eventHub from '../event_hub'; + +export default { + components: { + Icon, + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + autoStopUrl: { + type: String, + required: true, + }, + }, + methods: { + onPinClick() { + eventHub.$emit('cancelAutoStop', this.autoStopUrl); + }, + }, + title: __('Prevent environment from auto-stopping'), +}; +</script> +<template> + <gl-button v-gl-tooltip :title="$options.title" :aria-label="$options.title" @click="onPinClick"> + <icon name="thumbtack" /> + </gl-button> +</template> diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue index bafbc00597e..6279bbc83ee 100644 --- a/app/assets/javascripts/environments/components/environment_rollback.vue +++ b/app/assets/javascripts/environments/components/environment_rollback.vue @@ -8,7 +8,6 @@ import { GlTooltipDirective, GlLoadingIcon, GlModalDirective, GlButton } from '@gitlab/ui'; import { s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; -import ConfirmRollbackModal from './confirm_rollback_modal.vue'; import eventHub from '../event_hub'; export default { @@ -16,7 +15,6 @@ export default { Icon, GlLoadingIcon, GlButton, - ConfirmRollbackModal, }, directives: { GlTooltip: GlTooltipDirective, diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 453e7610e21..30299ccc7bc 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -6,6 +6,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import _ from 'underscore'; import environmentTableMixin from 'ee_else_ce/environments/mixins/environments_table_mixin'; import { s__ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import EnvironmentItem from './environment_item.vue'; export default { @@ -16,7 +17,7 @@ export default { CanaryDeploymentCallout: () => import('ee_component/environments/components/canary_deployment_callout.vue'), }, - mixins: [environmentTableMixin], + mixins: [environmentTableMixin, glFeatureFlagsMixin()], props: { environments: { type: Array, @@ -42,6 +43,9 @@ export default { : env, ); }, + shouldShowAutoStopDate() { + return this.glFeatures.autoStopEnvironments; + }, tableData() { return { // percent spacing for cols, should add up to 100 @@ -65,8 +69,12 @@ export default { title: s__('Environments|Updated'), spacing: 'section-10', }, + autoStop: { + title: s__('Environments|Auto stop in'), + spacing: 'section-5', + }, actions: { - spacing: 'section-30', + spacing: this.shouldShowAutoStopDate ? 'section-25' : 'section-30', }, }; }, @@ -123,6 +131,14 @@ export default { <div class="table-section" :class="tableData.date.spacing" role="columnheader"> {{ tableData.date.title }} </div> + <div + v-if="shouldShowAutoStopDate" + class="table-section" + :class="tableData.autoStop.spacing" + role="columnheader" + > + {{ tableData.autoStop.title }} + </div> </div> <template v-for="(model, i) in sortedEnvironments" :model="model"> <div @@ -130,6 +146,7 @@ export default { :key="`environment-item-${i}`" :model="model" :can-read-environment="canReadEnvironment" + :should-show-auto-stop-date="shouldShowAutoStopDate" :table-data="tableData" /> diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue index 1ea4e30a7c1..43ebd7b2824 100644 --- a/app/assets/javascripts/environments/components/stop_environment_modal.vue +++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue @@ -3,7 +3,6 @@ import { GlTooltipDirective } from '@gitlab/ui'; import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import { s__, sprintf } from '~/locale'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; import eventHub from '../event_hub'; export default { @@ -12,7 +11,6 @@ export default { components: { GlModal: DeprecatedModal2, - LoadingButton, }, directives: { diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js index 31347d95a25..34374e306a4 100644 --- a/app/assets/javascripts/environments/mixins/environments_mixin.js +++ b/app/assets/javascripts/environments/mixins/environments_mixin.js @@ -90,16 +90,19 @@ export default { Flash(s__('Environments|An error occurred while fetching the environments.')); }, - postAction({ endpoint, errorMessage }) { + postAction({ + endpoint, + errorMessage = s__('Environments|An error occurred while making the request.'), + }) { if (!this.isMakingRequest) { this.isLoading = true; this.service .postAction(endpoint) .then(() => this.fetchEnvironments()) - .catch(() => { + .catch(err => { this.isLoading = false; - Flash(errorMessage || s__('Environments|An error occurred while making the request.')); + Flash(_.isFunction(errorMessage) ? errorMessage(err.response.data) : errorMessage); }); } }, @@ -138,6 +141,13 @@ export default { ); this.postAction({ endpoint: retryUrl, errorMessage }); }, + + cancelAutoStop(autoStopPath) { + const errorMessage = ({ message }) => + message || + s__('Environments|An error occurred while canceling the auto stop, please try again'); + this.postAction({ endpoint: autoStopPath, errorMessage }); + }, }, computed: { @@ -199,6 +209,8 @@ export default { eventHub.$on('requestRollbackEnvironment', this.updateRollbackModal); eventHub.$on('rollbackEnvironment', this.rollbackEnvironment); + + eventHub.$on('cancelAutoStop', this.cancelAutoStop); }, beforeDestroy() { @@ -208,5 +220,7 @@ export default { eventHub.$off('requestRollbackEnvironment', this.updateRollbackModal); eventHub.$off('rollbackEnvironment', this.rollbackEnvironment); + + eventHub.$off('cancelAutoStop', this.cancelAutoStop); }, }; diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index 14b2e59009a..819d501cba6 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -1,7 +1,8 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import dateFormat from 'dateformat'; -import { GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { GlButton, GlFormInput, GlLink, GlLoadingIcon, GlBadge } from '@gitlab/ui'; import { __, sprintf, n__ } from '~/locale'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; import Icon from '~/vue_shared/components/icon.vue'; @@ -11,21 +12,41 @@ import TrackEventDirective from '~/vue_shared/directives/track_event'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import { trackClickErrorLinkToSentryOptions } from '../utils'; +import query from '../queries/details.query.graphql'; + export default { components: { LoadingButton, + GlButton, GlFormInput, GlLink, GlLoadingIcon, TooltipOnTruncate, Icon, Stacktrace, + GlBadge, }, directives: { TrackEvent: TrackEventDirective, }, mixins: [timeagoMixin], props: { + listPath: { + type: String, + required: true, + }, + issueUpdatePath: { + type: String, + required: true, + }, + issueId: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, issueDetailsPath: { type: String, required: true, @@ -43,38 +64,67 @@ export default { required: true, }, }, + apollo: { + GQLerror: { + query, + variables() { + return { + fullPath: this.projectPath, + errorId: `gid://gitlab/Gitlab::ErrorTracking::DetailedError/${this.issueId}`, + }; + }, + pollInterval: 2000, + update: data => data.project.sentryDetailedError, + error: () => createFlash(__('Failed to load error details from Sentry.')), + result(res) { + if (res.data.project?.sentryDetailedError) { + this.$apollo.queries.GQLerror.stopPolling(); + } + }, + }, + }, data() { return { + GQLerror: null, issueCreationInProgress: false, }; }, computed: { - ...mapState('details', ['error', 'loading', 'loadingStacktrace', 'stacktraceData']), + ...mapState('details', [ + 'error', + 'loading', + 'loadingStacktrace', + 'stacktraceData', + 'updatingResolveStatus', + 'updatingIgnoreStatus', + ]), ...mapGetters('details', ['stacktrace']), reported() { return sprintf( __('Reported %{timeAgo} by %{reportedBy}'), { - reportedBy: `<strong>${this.error.culprit}</strong>`, + reportedBy: `<strong>${this.GQLerror.culprit}</strong>`, timeAgo: this.timeFormatted(this.stacktraceData.date_received), }, false, ); }, firstReleaseLink() { - return `${this.error.external_base_url}/releases/${this.error.first_release_short_version}`; + return `${this.error.external_base_url}/releases/${this.GQLerror.firstReleaseShortVersion}`; }, lastReleaseLink() { - return `${this.error.external_base_url}releases/${this.error.last_release_short_version}`; + return `${this.error.external_base_url}releases/${this.GQLerror.lastReleaseShortVersion}`; }, showDetails() { - return Boolean(!this.loading && this.error && this.error.id); + return Boolean( + !this.loading && !this.$apollo.queries.GQLerror.loading && this.error && this.GQLerror, + ); }, showStacktrace() { return Boolean(!this.loadingStacktrace && this.stacktrace && this.stacktrace.length); }, issueTitle() { - return this.error.title; + return this.GQLerror.title; }, issueDescription() { return sprintf( @@ -83,29 +133,35 @@ export default { ), { description: '# Error Details:\n', - errorUrl: `${this.error.external_url}\n`, - firstSeen: `\n${this.error.first_seen}\n`, - lastSeen: `${this.error.last_seen}\n`, - countLabel: n__('- Event', '- Events', this.error.count), - count: `${this.error.count}\n`, - userCountLabel: n__('- User', '- Users', this.error.user_count), - userCount: `${this.error.user_count}\n`, + errorUrl: `${this.GQLerror.externalUrl}\n`, + firstSeen: `\n${this.GQLerror.firstSeen}\n`, + lastSeen: `${this.GQLerror.lastSeen}\n`, + countLabel: n__('- Event', '- Events', this.GQLerror.count), + count: `${this.GQLerror.count}\n`, + userCountLabel: n__('- User', '- Users', this.GQLerror.userCount), + userCount: `${this.GQLerror.userCount}\n`, }, false, ); }, + errorLevel() { + return sprintf(__('level: %{level}'), { level: this.error.tags.level }); + }, }, mounted() { this.startPollingDetails(this.issueDetailsPath); this.startPollingStacktrace(this.issueStackTracePath); }, methods: { - ...mapActions('details', ['startPollingDetails', 'startPollingStacktrace']), + ...mapActions('details', ['startPollingDetails', 'startPollingStacktrace', 'updateStatus']), trackClickErrorLinkToSentryOptions, createIssue() { this.issueCreationInProgress = true; this.$refs.sentryIssueForm.submit(); }, + updateIssueStatus(status) { + this.updateStatus({ endpoint: this.issueUpdatePath, redirectUrl: this.listPath, status }); + }, formatDate(date) { return `${this.timeFormatted(date)} (${dateFormat(date, 'UTC:yyyy-mm-dd h:MM:ssTT Z')})`; }, @@ -115,75 +171,118 @@ export default { <template> <div> - <div v-if="loading" class="py-3"> + <div v-if="$apollo.queries.GQLerror.loading || loading" class="py-3"> <gl-loading-icon :size="3" /> </div> <div v-else-if="showDetails" class="error-details"> <div class="top-area align-items-center justify-content-between py-3"> <span v-if="!loadingStacktrace && stacktrace" v-html="reported"></span> - <form ref="sentryIssueForm" :action="projectIssuesPath" method="POST"> - <gl-form-input class="hidden" name="issue[title]" :value="issueTitle" /> - <input name="issue[description]" :value="issueDescription" type="hidden" /> - <gl-form-input - :value="error.id" - class="hidden" - name="issue[sentry_issue_attributes][sentry_issue_identifier]" + <div class="d-inline-flex"> + <loading-button + :label="__('Ignore')" + :loading="updatingIgnoreStatus" + @click="updateIssueStatus('ignored')" /> - <gl-form-input :value="csrfToken" class="hidden" name="authenticity_token" /> <loading-button - v-if="!error.gitlab_issue" - class="btn-success" - :label="__('Create issue')" - :loading="issueCreationInProgress" - data-qa-selector="create_issue_button" - @click="createIssue" + class="btn-outline-info ml-2" + :label="__('Resolve')" + :loading="updatingResolveStatus" + @click="updateIssueStatus('resolved')" /> - </form> + <gl-button + v-if="error.gitlab_issue" + class="ml-2" + data-qa-selector="view_issue_button" + :href="error.gitlab_issue" + variant="success" + > + {{ __('View issue') }} + </gl-button> + <form + ref="sentryIssueForm" + :action="projectIssuesPath" + method="POST" + class="d-inline-block ml-2" + > + <gl-form-input class="hidden" name="issue[title]" :value="issueTitle" /> + <input name="issue[description]" :value="issueDescription" type="hidden" /> + <gl-form-input + :value="GQLerror.sentryId" + class="hidden" + name="issue[sentry_issue_attributes][sentry_issue_identifier]" + /> + <gl-form-input :value="csrfToken" class="hidden" name="authenticity_token" /> + <loading-button + v-if="!error.gitlab_issue" + class="btn-success" + :label="__('Create issue')" + :loading="issueCreationInProgress" + data-qa-selector="create_issue_button" + @click="createIssue" + /> + </form> + </div> </div> <div> - <tooltip-on-truncate :title="error.title" truncate-target="child" placement="top"> - <h2 class="text-truncate">{{ error.title }}</h2> + <tooltip-on-truncate :title="GQLerror.title" truncate-target="child" placement="top"> + <h2 class="text-truncate">{{ GQLerror.title }}</h2> </tooltip-on-truncate> - <h3>{{ __('Error details') }}</h3> + <template v-if="error.tags"> + <gl-badge v-if="error.tags.level" variant="danger" class="rounded-pill mr-2" + >{{ errorLevel }} + </gl-badge> + <gl-badge v-if="error.tags.logger" variant="light" class="rounded-pill" + >{{ error.tags.logger }} + </gl-badge> + </template> <ul> + <li v-if="GQLerror.gitlabCommit"> + <strong class="bold">{{ __('GitLab commit') }}:</strong> + <gl-link :href="GQLerror.gitlabCommitPath"> + <span>{{ GQLerror.gitlabCommit.substr(0, 10) }}</span> + </gl-link> + </li> <li v-if="error.gitlab_issue"> - <span class="bold">{{ __('GitLab Issue') }}:</span> + <strong class="bold">{{ __('GitLab Issue') }}:</strong> <gl-link :href="error.gitlab_issue"> <span>{{ error.gitlab_issue }}</span> </gl-link> </li> <li> - <span class="bold">{{ __('Sentry event') }}:</span> + <strong class="bold">{{ __('Sentry event') }}:</strong> <gl-link - v-track-event="trackClickErrorLinkToSentryOptions(error.external_url)" - :href="error.external_url" + v-track-event="trackClickErrorLinkToSentryOptions(GQLerror.externalUrl)" + class="d-inline-flex align-items-center" + :href="GQLerror.externalUrl" target="_blank" > - <span class="text-truncate">{{ error.external_url }}</span> + <span class="text-truncate">{{ GQLerror.externalUrl }}</span> <icon name="external-link" class="ml-1 flex-shrink-0" /> </gl-link> </li> - <li v-if="error.first_release_short_version"> - <span class="bold">{{ __('First seen') }}:</span> - {{ formatDate(error.first_seen) }} + <li v-if="GQLerror.firstReleaseShortVersion"> + <strong class="bold">{{ __('First seen') }}:</strong> + {{ formatDate(GQLerror.firstSeen) }} <gl-link :href="firstReleaseLink" target="_blank"> - <span>{{ __('Release') }}: {{ error.first_release_short_version }}</span> + <span> + {{ __('Release') }}: {{ GQLerror.firstReleaseShortVersion.substr(0, 10) }} + </span> </gl-link> </li> - <li v-if="error.last_release_short_version"> - <span class="bold">{{ __('Last seen') }}:</span> - {{ formatDate(error.last_seen) }} + <li v-if="GQLerror.lastReleaseShortVersion"> + <strong class="bold">{{ __('Last seen') }}:</strong> + {{ formatDate(GQLerror.lastSeen) }} <gl-link :href="lastReleaseLink" target="_blank"> - <span>{{ __('Release') }}: {{ error.last_release_short_version }}</span> + <span>{{ __('Release') }}: {{ GQLerror.lastReleaseShortVersion.substr(0, 10) }}</span> </gl-link> </li> <li> - <span class="bold">{{ __('Events') }}:</span> - <span>{{ error.count }}</span> + <strong class="bold">{{ __('Events') }}:</strong> + <span>{{ GQLerror.count }}</span> </li> <li> - <span class="bold">{{ __('Users') }}:</span> - <span>{{ error.user_count }}</span> + <strong class="bold">{{ __('Users') }}:</strong> + <span>{{ GQLerror.userCount }}</span> </li> </ul> diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue index 8e2128ac713..3280ff48129 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -25,10 +25,47 @@ export default { PREV_PAGE: 1, NEXT_PAGE: 2, fields: [ - { key: 'error', label: __('Open errors'), thClass: 'w-70p' }, - { key: 'events', label: __('Events') }, - { key: 'users', label: __('Users') }, - { key: 'lastSeen', label: __('Last seen'), thClass: 'w-15p' }, + { + key: 'error', + label: __('Error'), + thClass: 'w-60p', + tdClass: 'table-col d-flex d-sm-table-cell px-3', + }, + { + key: 'events', + label: __('Events'), + thClass: 'text-right', + tdClass: 'table-col d-flex d-sm-table-cell', + }, + { + key: 'users', + label: __('Users'), + thClass: 'text-right', + tdClass: 'table-col d-flex d-sm-table-cell', + }, + { + key: 'lastSeen', + label: __('Last seen'), + thClass: '', + tdClass: 'table-col d-flex d-sm-table-cell', + }, + { + key: 'ignore', + label: '', + thClass: 'w-3rem', + tdClass: 'table-col d-flex pl-0 d-sm-table-cell', + }, + { + key: 'resolved', + label: '', + thClass: 'w-3rem', + tdClass: 'table-col d-flex pl-0 d-sm-table-cell', + }, + { + key: 'details', + tdClass: 'table-col d-sm-none d-flex align-items-center', + thClass: 'invisible w-0', + }, ], sortFields: { last_seen: __('Last Seen'), @@ -74,6 +111,14 @@ export default { type: Boolean, required: true, }, + projectPath: { + type: String, + required: true, + }, + listPath: { + type: String, + required: true, + }, }, hasLocalStorage: AccessorUtils.isLocalStorageAccessSafe(), data() { @@ -90,6 +135,7 @@ export default { 'sortField', 'recentSearches', 'pagination', + 'cursor', ]), paginationRequired() { return !_.isEmpty(this.pagination); @@ -119,6 +165,8 @@ export default { 'clearRecentSearches', 'loadRecentSearches', 'setIndexPath', + 'fetchPaginatedResults', + 'updateStatus', ]), setSearchText(text) { this.errorSearchQuery = text; @@ -129,10 +177,10 @@ export default { }, goToNextPage() { this.pageValue = this.$options.NEXT_PAGE; - this.startPolling(`${this.indexPath}?cursor=${this.pagination.next.cursor}`); + this.fetchPaginatedResults(this.pagination.next.cursor); }, goToPrevPage() { - this.startPolling(`${this.indexPath}?cursor=${this.pagination.previous.cursor}`); + this.fetchPaginatedResults(this.pagination.previous.cursor); }, goToPage(page) { window.scrollTo(0, 0); @@ -141,6 +189,16 @@ export default { isCurrentSortField(field) { return field === this.sortField; }, + getIssueUpdatePath(errorId) { + return `/${this.projectPath}/-/error_tracking/${errorId}.json`; + }, + updateIssueStatus(errorId, status) { + this.updateStatus({ + endpoint: this.getIssueUpdatePath(errorId), + redirectUrl: this.listPath, + status, + }); + }, }, }; </script> @@ -148,62 +206,62 @@ export default { <template> <div class="error-list"> <div v-if="errorTrackingEnabled"> - <div - class="d-flex flex-row justify-content-around align-items-center bg-secondary border mt-2" - > - <div class="filtered-search-box flex-grow-1 my-3 ml-3 mr-2"> - <gl-dropdown - :text="__('Recent searches')" - class="filtered-search-history-dropdown-wrapper d-none d-md-block" - toggle-class="filtered-search-history-dropdown-toggle-button" - :disabled="loading" - > - <div v-if="!$options.hasLocalStorage" class="px-3"> - {{ __('This feature requires local storage to be enabled') }} - </div> - <template v-else-if="recentSearches.length > 0"> - <gl-dropdown-item - v-for="searchQuery in recentSearches" - :key="searchQuery" - @click="setSearchText(searchQuery)" - >{{ searchQuery }}</gl-dropdown-item - > - <gl-dropdown-divider /> - <gl-dropdown-item ref="clearRecentSearches" @click="clearRecentSearches">{{ - __('Clear recent searches') - }}</gl-dropdown-item> - </template> - <div v-else class="px-3">{{ __("You don't have any recent searches") }}</div> - </gl-dropdown> - <div class="filtered-search-input-container flex-fill"> - <gl-form-input - v-model="errorSearchQuery" - class="pl-2 filtered-search" + <div class="row flex-column flex-sm-row align-items-sm-center row-top m-0 mt-sm-2 p-0 p-sm-3"> + <div class="search-box flex-fill mr-sm-2 my-3 m-sm-0 p-3 p-sm-0"> + <div class="filtered-search-box mb-0"> + <gl-dropdown + :text="__('Recent searches')" + class="filtered-search-history-dropdown-wrapper" + toggle-class="filtered-search-history-dropdown-toggle-button" :disabled="loading" - :placeholder="__('Search or filter resultsā¦')" - autofocus - @keyup.enter.native="searchByQuery(errorSearchQuery)" - /> - </div> - <div class="gl-search-box-by-type-right-icons"> - <gl-button - v-if="errorSearchQuery.length > 0" - v-gl-tooltip.hover - :title="__('Clear')" - class="clear-search text-secondary" - name="clear" - @click="errorSearchQuery = ''" > - <gl-icon name="close" :size="12" /> - </gl-button> + <div v-if="!$options.hasLocalStorage" class="px-3"> + {{ __('This feature requires local storage to be enabled') }} + </div> + <template v-else-if="recentSearches.length > 0"> + <gl-dropdown-item + v-for="searchQuery in recentSearches" + :key="searchQuery" + @click="setSearchText(searchQuery)" + >{{ searchQuery }} + </gl-dropdown-item> + <gl-dropdown-divider /> + <gl-dropdown-item ref="clearRecentSearches" @click="clearRecentSearches" + >{{ __('Clear recent searches') }} + </gl-dropdown-item> + </template> + <div v-else class="px-3">{{ __("You don't have any recent searches") }}</div> + </gl-dropdown> + <div class="filtered-search-input-container flex-fill"> + <gl-form-input + v-model="errorSearchQuery" + class="pl-2 filtered-search" + :disabled="loading" + :placeholder="__('Search or filter resultsā¦')" + autofocus + @keyup.enter.native="searchByQuery(errorSearchQuery)" + /> + </div> + <div class="gl-search-box-by-type-right-icons"> + <gl-button + v-if="errorSearchQuery.length > 0" + v-gl-tooltip.hover + :title="__('Clear')" + class="clear-search text-secondary" + name="clear" + @click="errorSearchQuery = ''" + > + <gl-icon name="close" :size="12" /> + </gl-button> + </div> </div> </div> <gl-dropdown + class="sort-control" :text="$options.sortFields[sortField]" left :disabled="loading" - class="mr-3" menu-class="sort-dropdown" > <gl-dropdown-item @@ -227,62 +285,97 @@ export default { <gl-loading-icon size="md" /> </div> - <gl-table - v-else - class="mt-3" - :items="errors" - :fields="$options.fields" - :show-empty="true" - fixed - stacked="sm" - > - <template slot="HEAD_events" slot-scope="data"> - <div class="text-md-right">{{ data.label }}</div> - </template> - <template slot="HEAD_users" slot-scope="data"> - <div class="text-md-right">{{ data.label }}</div> - </template> - <template slot="error" slot-scope="errors"> - <div class="d-flex flex-column"> - <gl-link class="d-flex text-dark" :href="getDetailsLink(errors.item.id)"> - <strong class="text-truncate">{{ errors.item.title.trim() }}</strong> - </gl-link> - <span class="text-secondary text-truncate"> - {{ errors.item.culprit }} - </span> - </div> - </template> - <template slot="events" slot-scope="errors"> - <div class="text-md-right">{{ errors.item.count }}</div> - </template> + <template v-else> + <h4 class="d-block d-sm-none my-3">{{ __('Open errors') }}</h4> - <template slot="users" slot-scope="errors"> - <div class="text-md-right">{{ errors.item.userCount }}</div> - </template> + <gl-table + class="mt-3" + :items="errors" + :fields="$options.fields" + :show-empty="true" + fixed + stacked="sm" + tbody-tr-class="table-row mb-4" + > + <template v-slot:head(error)> + <div class="d-none d-sm-block">{{ __('Open errors') }}</div> + </template> + <template v-slot:head(events)="data"> + <div class="text-sm-right">{{ data.label }}</div> + </template> + <template v-slot:head(users)="data"> + <div class="text-sm-right">{{ data.label }}</div> + </template> - <template slot="lastSeen" slot-scope="errors"> - <div class="d-flex align-items-center"> - <time-ago :time="errors.item.lastSeen" class="text-secondary" /> - </div> - </template> - <template slot="empty"> - <div ref="empty"> + <template v-slot:error="errors"> + <div class="d-flex flex-column"> + <gl-link class="d-flex mw-100 text-dark" :href="getDetailsLink(errors.item.id)"> + <strong class="text-truncate">{{ errors.item.title.trim() }}</strong> + </gl-link> + <span class="text-secondary text-truncate mw-100"> + {{ errors.item.culprit }} + </span> + </div> + </template> + <template v-slot:events="errors"> + <div class="text-right">{{ errors.item.count }}</div> + </template> + + <template v-slot:users="errors"> + <div class="text-right">{{ errors.item.userCount }}</div> + </template> + + <template v-slot:lastSeen="errors"> + <div class="text-md-left text-right"> + <time-ago :time="errors.item.lastSeen" class="text-secondary" /> + </div> + </template> + <template v-slot:ignore="errors"> + <gl-button + ref="ignoreError" + v-gl-tooltip.hover + :title="__('Ignore')" + @click="updateIssueStatus(errors.item.id, 'ignored')" + > + <gl-icon name="eye-slash" :size="12" /> + </gl-button> + </template> + <template v-slot:resolved="errors"> + <gl-button + ref="resolveError" + v-gl-tooltip + :title="__('Resolve')" + @click="updateIssueStatus(errors.item.id, 'resolved')" + > + <gl-icon name="check-circle" :size="12" /> + </gl-button> + </template> + <template v-slot:details="errors"> + <gl-button + :href="getDetailsLink(errors.item.id)" + variant="outline-info" + class="d-block" + > + {{ __('More details') }} + </gl-button> + </template> + <template v-slot:empty> {{ __('No errors to display.') }} <gl-link class="js-try-again" @click="restartPolling"> {{ __('Check again') }} </gl-link> - </div> - </template> - </gl-table> - <gl-pagination - v-show="!loading" - v-if="paginationRequired" - :prev-page="$options.PREV_PAGE" - :next-page="$options.NEXT_PAGE" - :value="pageValue" - align="center" - @input="goToPage" - /> + </template> + </gl-table> + <gl-pagination + v-show="!loading" + v-if="paginationRequired" + :prev-page="$options.PREV_PAGE" + :next-page="$options.NEXT_PAGE" + :value="pageValue" + align="center" + @input="goToPage" + /> + </template> </div> <div v-else-if="userCanEnableErrorTracking"> <gl-empty-state diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue index 62fd379aa4c..4e63e167260 100644 --- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue +++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue @@ -1,4 +1,5 @@ <script> +import _ from 'underscore'; import { GlTooltip } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -56,17 +57,36 @@ export default { collapseIcon() { return this.isExpanded ? 'chevron-down' : 'chevron-right'; }, - noCodeFn() { - return this.errorFn ? sprintf(__('in %{errorFn} '), { errorFn: this.errorFn }) : ''; + errorFnText() { + return this.errorFn + ? sprintf( + __(`%{spanStart}in%{spanEnd} %{errorFn}`), + { + errorFn: `<strong>${_.escape(this.errorFn)}</strong>`, + spanStart: `<span class="text-tertiary">`, + spanEnd: `</span>`, + }, + false, + ) + : ''; }, - noCodeLine() { + errorPositionText() { return this.errorLine - ? sprintf(__('at line %{errorLine}%{errorColumn}'), { - errorLine: this.errorLine, - errorColumn: this.errorColumn ? `:${this.errorColumn}` : '', - }) + ? sprintf( + __(`%{spanStart}at line%{spanEnd} %{errorLine}%{errorColumn}`), + { + errorLine: `<strong>${this.errorLine}</strong>`, + errorColumn: this.errorColumn ? `:<strong>${this.errorColumn}</strong>` : ``, + spanStart: `<span class="text-tertiary">`, + spanEnd: `</span>`, + }, + false, + ) : ''; }, + errorInfo() { + return `${this.errorFnText} ${this.errorPositionText}`; + }, }, methods: { isHighlighted(lineNum) { @@ -102,8 +122,7 @@ export default { <strong v-gl-tooltip :title="filePath" - class="file-title-name d-inline-block overflow-hidden text-truncate" - :class="{ 'limited-width': !hasCode }" + class="file-title-name d-inline-block overflow-hidden text-truncate limited-width" data-container="body" > {{ filePath }} @@ -113,7 +132,7 @@ export default { :text="filePath" css-class="btn-default btn-transparent btn-clipboard position-static" /> - <span v-if="!hasCode" class="text-tertiary">{{ noCodeFn }}{{ noCodeLine }}</span> + <span v-html="errorInfo"></span> </div> </div> diff --git a/app/assets/javascripts/error_tracking/details.js b/app/assets/javascripts/error_tracking/details.js index 872cb8868a2..c18298dec4f 100644 --- a/app/assets/javascripts/error_tracking/details.js +++ b/app/assets/javascripts/error_tracking/details.js @@ -1,22 +1,43 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import store from './store'; import ErrorDetails from './components/error_details.vue'; import csrf from '~/lib/utils/csrf'; +Vue.use(VueApollo); + export default () => { + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + // eslint-disable-next-line no-new new Vue({ el: '#js-error_details', + apolloProvider, components: { ErrorDetails, }, store, render(createElement) { const domEl = document.querySelector(this.$options.el); - const { issueDetailsPath, issueStackTracePath, projectIssuesPath } = domEl.dataset; + const { + issueId, + projectPath, + listPath, + issueUpdatePath, + issueDetailsPath, + issueStackTracePath, + projectIssuesPath, + } = domEl.dataset; return createElement('error-details', { props: { + issueId, + projectPath, + listPath, + issueUpdatePath, issueDetailsPath, issueStackTracePath, projectIssuesPath, diff --git a/app/assets/javascripts/error_tracking/list.js b/app/assets/javascripts/error_tracking/list.js index 073e2c8f1c7..8f3700249da 100644 --- a/app/assets/javascripts/error_tracking/list.js +++ b/app/assets/javascripts/error_tracking/list.js @@ -13,7 +13,13 @@ export default () => { store, render(createElement) { const domEl = document.querySelector(this.$options.el); - const { indexPath, enableErrorTrackingLink, illustrationPath } = domEl.dataset; + const { + indexPath, + enableErrorTrackingLink, + illustrationPath, + projectPath, + listPath, + } = domEl.dataset; let { errorTrackingEnabled, userCanEnableErrorTracking } = domEl.dataset; errorTrackingEnabled = parseBoolean(errorTrackingEnabled); @@ -26,6 +32,8 @@ export default () => { errorTrackingEnabled, illustrationPath, userCanEnableErrorTracking, + projectPath, + listPath, }, }); }, diff --git a/app/assets/javascripts/error_tracking/queries/details.query.graphql b/app/assets/javascripts/error_tracking/queries/details.query.graphql new file mode 100644 index 00000000000..625ce3030d9 --- /dev/null +++ b/app/assets/javascripts/error_tracking/queries/details.query.graphql @@ -0,0 +1,20 @@ +query errorDetails($fullPath: ID!, $errorId: ID!) { + project(fullPath: $fullPath) { + sentryDetailedError(id: $errorId) { + id + sentryId + title + userCount + count + firstSeen + lastSeen + message + culprit + externalUrl + firstReleaseShortVersion + lastReleaseShortVersion + gitlabCommit + gitlabCommitPath + } + } +} diff --git a/app/assets/javascripts/error_tracking/services/index.js b/app/assets/javascripts/error_tracking/services/index.js index 3b3f8311d67..3fb317c17f5 100644 --- a/app/assets/javascripts/error_tracking/services/index.js +++ b/app/assets/javascripts/error_tracking/services/index.js @@ -4,4 +4,7 @@ export default { getSentryData({ endpoint, params }) { return axios.get(endpoint, { params }); }, + updateErrorStatus(endpoint, status) { + return axios.put(endpoint, { status }); + }, }; diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/actions.js new file mode 100644 index 00000000000..bb8b039b5df --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/actions.js @@ -0,0 +1,19 @@ +import service from './../services'; +import * as types from './mutation_types'; +import createFlash from '~/flash'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; + +export function updateStatus({ commit }, { endpoint, redirectUrl, status }) { + const type = + status === 'resolved' ? types.SET_UPDATING_RESOLVE_STATUS : types.SET_UPDATING_IGNORE_STATUS; + commit(type, true); + + return service + .updateErrorStatus(endpoint, status) + .then(() => visitUrl(redirectUrl)) + .catch(() => createFlash(__('Failed to update issue status'))) + .finally(() => commit(type, false)); +} + +export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/details/state.js b/app/assets/javascripts/error_tracking/store/details/state.js index 95fb0ba0558..52b0297607d 100644 --- a/app/assets/javascripts/error_tracking/store/details/state.js +++ b/app/assets/javascripts/error_tracking/store/details/state.js @@ -3,4 +3,6 @@ export default () => ({ stacktraceData: {}, loading: true, loadingStacktrace: true, + updatingResolveStatus: false, + updatingIgnoreStatus: false, }); diff --git a/app/assets/javascripts/error_tracking/store/index.js b/app/assets/javascripts/error_tracking/store/index.js index ad05eecef6c..d9206bc8d7c 100644 --- a/app/assets/javascripts/error_tracking/store/index.js +++ b/app/assets/javascripts/error_tracking/store/index.js @@ -1,6 +1,9 @@ import Vue from 'vue'; import Vuex from 'vuex'; +import * as actions from './actions'; +import mutations from './mutations'; + import * as listActions from './list/actions'; import listMutations from './list/mutations'; import listState from './list/state'; @@ -18,14 +21,14 @@ export const createStore = () => list: { namespaced: true, state: listState(), - actions: listActions, - mutations: listMutations, + actions: { ...actions, ...listActions }, + mutations: { ...mutations, ...listMutations }, }, details: { namespaced: true, state: detailsState(), - actions: detailsActions, - mutations: detailsMutations, + actions: { ...actions, ...detailsActions }, + mutations: { ...mutations, ...detailsMutations }, getters: detailsGetters, }, }, diff --git a/app/assets/javascripts/error_tracking/store/list/actions.js b/app/assets/javascripts/error_tracking/store/list/actions.js index c9e882c4ed2..d96ac7f524e 100644 --- a/app/assets/javascripts/error_tracking/store/list/actions.js +++ b/app/assets/javascripts/error_tracking/store/list/actions.js @@ -17,12 +17,14 @@ export function startPolling({ state, commit, dispatch }) { params: { search_term: state.searchQuery, sort: state.sortField, + cursor: state.cursor, }, }, successCallback: ({ data }) => { if (!data) { return; } + commit(types.SET_PAGINATION, data.pagination); commit(types.SET_ERRORS, data.errors); commit(types.SET_LOADING, false); @@ -74,6 +76,7 @@ export function clearRecentSearches({ commit }) { export const searchByQuery = ({ commit, dispatch }, query) => { const searchQuery = query.trim(); + commit(types.SET_CURSOR, null); commit(types.SET_SEARCH_QUERY, searchQuery); commit(types.ADD_RECENT_SEARCH, searchQuery); dispatch('stopPolling'); @@ -81,6 +84,7 @@ export const searchByQuery = ({ commit, dispatch }, query) => { }; export const sortByField = ({ commit, dispatch }, field) => { + commit(types.SET_CURSOR, null); commit(types.SET_SORT_FIELD, field); dispatch('stopPolling'); dispatch('startPolling'); @@ -90,4 +94,10 @@ export const setEndpoint = ({ commit }, endpoint) => { commit(types.SET_ENDPOINT, endpoint); }; +export const fetchPaginatedResults = ({ commit, dispatch }, cursor) => { + commit(types.SET_CURSOR, cursor); + dispatch('stopPolling'); + dispatch('startPolling'); +}; + export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/list/mutation_types.js b/app/assets/javascripts/error_tracking/store/list/mutation_types.js index 301984a1ee0..c3468b7eabd 100644 --- a/app/assets/javascripts/error_tracking/store/list/mutation_types.js +++ b/app/assets/javascripts/error_tracking/store/list/mutation_types.js @@ -8,3 +8,4 @@ export const SET_PAGINATION = 'SET_PAGINATION'; export const SET_ENDPOINT = 'SET_ENDPOINT'; export const SET_SORT_FIELD = 'SET_SORT_FIELD'; export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY'; +export const SET_CURSOR = 'SET_CURSOR'; diff --git a/app/assets/javascripts/error_tracking/store/list/mutations.js b/app/assets/javascripts/error_tracking/store/list/mutations.js index 5648013bb89..dd5cde0576a 100644 --- a/app/assets/javascripts/error_tracking/store/list/mutations.js +++ b/app/assets/javascripts/error_tracking/store/list/mutations.js @@ -47,6 +47,9 @@ export default { [types.SET_PAGINATION](state, pagination) { state.pagination = pagination; }, + [types.SET_CURSOR](state, cursor) { + state.cursor = cursor; + }, [types.SET_SORT_FIELD](state, field) { state.sortField = field; }, diff --git a/app/assets/javascripts/error_tracking/store/list/state.js b/app/assets/javascripts/error_tracking/store/list/state.js index 93dc1040fde..225a805e709 100644 --- a/app/assets/javascripts/error_tracking/store/list/state.js +++ b/app/assets/javascripts/error_tracking/store/list/state.js @@ -7,4 +7,5 @@ export default () => ({ indexPath: '', recentSearches: [], pagination: {}, + cursor: null, }); diff --git a/app/assets/javascripts/error_tracking/store/mutation_types.js b/app/assets/javascripts/error_tracking/store/mutation_types.js new file mode 100644 index 00000000000..30aebacbedd --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/mutation_types.js @@ -0,0 +1,2 @@ +export const SET_UPDATING_RESOLVE_STATUS = 'SET_UPDATING_RESOLVE_STATUS'; +export const SET_UPDATING_IGNORE_STATUS = 'SET_UPDATING_IGNORE_STATUS'; diff --git a/app/assets/javascripts/error_tracking/store/mutations.js b/app/assets/javascripts/error_tracking/store/mutations.js new file mode 100644 index 00000000000..c7a7e46df40 --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/mutations.js @@ -0,0 +1,10 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_UPDATING_IGNORE_STATUS](state, updating) { + state.updatingIgnoreStatus = updating; + }, + [types.SET_UPDATING_RESOLVE_STATUS](state, updating) { + state.updatingResolveStatus = updating; + }, +}; diff --git a/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue index 82df02afafd..11fd06fb40b 100644 --- a/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue +++ b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue @@ -1,14 +1,11 @@ <script> -import { GlDropdown, GlDropdownHeader, GlDropdownItem } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { getDisplayName } from '../utils'; export default { components: { GlDropdown, - GlDropdownHeader, GlDropdownItem, - Icon, }, props: { dropdownLabel: { diff --git a/app/assets/javascripts/error_tracking_settings/store/actions.js b/app/assets/javascripts/error_tracking_settings/store/actions.js index 6b540ea7dfd..3f1ac426278 100644 --- a/app/assets/javascripts/error_tracking_settings/store/actions.js +++ b/app/assets/javascripts/error_tracking_settings/store/actions.js @@ -25,8 +25,8 @@ export const receiveProjectsError = ({ commit }) => { export const fetchProjects = ({ dispatch, state }) => { dispatch('requestProjects'); return axios - .post(state.listProjectsEndpoint, { - error_tracking_setting: { + .get(state.listProjectsEndpoint, { + params: { api_host: state.apiHost, token: state.token, }, diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_options.js b/app/assets/javascripts/feature_highlight/feature_highlight_options.js index 212643b1e04..c5553f0243f 100644 --- a/app/assets/javascripts/feature_highlight/feature_highlight_options.js +++ b/app/assets/javascripts/feature_highlight/feature_highlight_options.js @@ -1,8 +1,8 @@ +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { highlightFeatures } from './feature_highlight'; -import bp from '../breakpoints'; export default function domContentLoaded() { - if (bp.getBreakpointSize() === 'lg') { + if (bp.getBreakpointSize() === 'xl') { highlightFeatures(); return true; } diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js index c21fba06d42..be2eee828ff 100644 --- a/app/assets/javascripts/filterable_list.js +++ b/app/assets/javascripts/filterable_list.js @@ -64,6 +64,7 @@ export default class FilterableList { return false; } + // eslint-disable-next-line no-jquery/no-fade $(this.listHolderElement).fadeTo(250, 0.5); this.isBusy = true; @@ -98,6 +99,7 @@ export default class FilterableList { onFilterComplete() { this.isBusy = false; + // eslint-disable-next-line no-jquery/no-fade $(this.listHolderElement).fadeTo(250, 1); } } diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js index e020628a473..9440015b32e 100644 --- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js +++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js @@ -2,6 +2,7 @@ import { __ } from '~/locale'; export default IssuableTokenKeys => { const wipToken = { + formattedKey: __('WIP'), key: 'wip', type: 'string', param: '', @@ -17,6 +18,7 @@ export default IssuableTokenKeys => { IssuableTokenKeys.tokenKeysWithAlternative.push(wipToken); const targetBranchToken = { + formattedKey: __('Target-Branch'), key: 'target-branch', type: 'string', param: '', diff --git a/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js index 691d165c585..42d0fbacca0 100644 --- a/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js @@ -1,7 +1,9 @@ +import { __ } from '~/locale'; import FilteredSearchTokenKeys from './filtered_search_token_keys'; const tokenKeys = [ { + formattedKey: __('Status'), key: 'status', type: 'string', param: 'status', @@ -10,6 +12,7 @@ const tokenKeys = [ tag: 'status', }, { + formattedKey: __('Type'), key: 'type', type: 'string', param: 'type', @@ -18,6 +21,7 @@ const tokenKeys = [ tag: 'type', }, { + formattedKey: __('Tag'), key: 'tag', type: 'array', param: 'name[]', diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js index 5fa07045d5e..5450abf4cbd 100644 --- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js +++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js @@ -4,6 +4,7 @@ import DropdownNonUser from './dropdown_non_user'; import DropdownEmoji from './dropdown_emoji'; import NullDropdown from './null_dropdown'; import DropdownAjaxFilter from './dropdown_ajax_filter'; +import DropdownOperator from './dropdown_operator'; import DropdownUtils from './dropdown_utils'; import { mergeUrlParams } from '../lib/utils/url_utility'; @@ -40,6 +41,11 @@ export default class AvailableDropdownMappings { gl: DropdownHint, element: this.container.querySelector('#js-dropdown-hint'), }, + operator: { + reference: null, + gl: DropdownOperator, + element: this.container.querySelector('#js-dropdown-operator'), + }, }; supportedTokens.forEach(type => { 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 4757c4b1e43..fa2609a3176 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 @@ -29,6 +29,7 @@ export default { const resultantTokens = tokens.map(token => ({ prefix: `${token.key}:`, + operator: token.operator, suffix: `${token.symbol}${token.value}`, })); @@ -75,6 +76,7 @@ export default { class="filtered-search-history-dropdown-token" > <span class="name">{{ token.prefix }}</span> + <span class="name">{{ token.operator }}</span> <span class="value">{{ token.suffix }}</span> </span> </span> diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js index b11111f1081..d7264e96b13 100644 --- a/app/assets/javascripts/filtered_search/constants.js +++ b/app/assets/javascripts/filtered_search/constants.js @@ -1,2 +1,6 @@ -/* eslint-disable import/prefer-default-export */ export const USER_TOKEN_TYPES = ['author', 'assignee']; + +export const DROPDOWN_TYPE = { + hint: 'hint', + operator: 'operator', +}; diff --git a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js index b27bb63c220..92a64ab60db 100644 --- a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js +++ b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js @@ -45,7 +45,7 @@ export default class DropdownAjaxFilter extends FilteredSearchDropdown { getSearchInput() { const query = DropdownUtils.getSearchInput(this.input); - const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get()); + const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.getKeys()); let value = lastToken || ''; diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js index 1a1135ae929..4f10b6ba9c3 100644 --- a/app/assets/javascripts/filtered_search/dropdown_hint.js +++ b/app/assets/javascripts/filtered_search/dropdown_hint.js @@ -3,6 +3,7 @@ import FilteredSearchDropdown from './filtered_search_dropdown'; import DropdownUtils from './dropdown_utils'; import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; +import { __ } from '~/locale'; export default class DropdownHint extends FilteredSearchDropdown { constructor(options = {}) { @@ -30,8 +31,8 @@ export default class DropdownHint extends FilteredSearchDropdown { this.dismissDropdown(); this.dispatchFormSubmitEvent(); } else { - const token = selected.querySelector('.js-filter-hint').innerText.trim(); - const tag = selected.querySelector('.js-filter-tag').innerText.trim(); + const filterItemEl = selected.closest('.filter-dropdown-item'); + const { hint: token, tag } = filterItemEl.dataset; if (tag.length) { // Get previous input values in the input field and convert them into visual tokens @@ -55,8 +56,13 @@ export default class DropdownHint extends FilteredSearchDropdown { const key = token.replace(':', ''); const { uppercaseTokenName } = this.tokenKeys.searchByKey(key); - FilteredSearchDropdownManager.addWordToInput(key, '', false, { - uppercaseTokenName, + + FilteredSearchDropdownManager.addWordToInput({ + tokenName: key, + clicked: false, + options: { + uppercaseTokenName, + }, }); } this.dismissDropdown(); @@ -66,15 +72,30 @@ export default class DropdownHint extends FilteredSearchDropdown { } renderContent() { - const dropdownData = this.tokenKeys.get().map(tokenKey => ({ - icon: `${gon.sprite_icons}#${tokenKey.icon}`, - hint: tokenKey.key, - tag: `:${tokenKey.tag}`, - type: tokenKey.type, - })); + const searchItem = [ + { + hint: 'search', + tag: 'search', + formattedKey: __('Search for this text'), + icon: `${gon.sprite_icons}#search`, + }, + ]; + + const dropdownData = this.tokenKeys + .get() + .map(tokenKey => ({ + icon: `${gon.sprite_icons}#${tokenKey.icon}`, + hint: tokenKey.key, + tag: `:${tokenKey.tag}`, + type: tokenKey.type, + formattedKey: tokenKey.formattedKey, + })) + .concat(searchItem); this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config); this.droplab.setData(this.hookId, dropdownData); + + super.renderContent(); } init() { diff --git a/app/assets/javascripts/filtered_search/dropdown_operator.js b/app/assets/javascripts/filtered_search/dropdown_operator.js new file mode 100644 index 00000000000..bd4fda29609 --- /dev/null +++ b/app/assets/javascripts/filtered_search/dropdown_operator.js @@ -0,0 +1,65 @@ +import Filter from '~/droplab/plugins/filter'; +import { __ } from '~/locale'; +import FilteredSearchDropdown from './filtered_search_dropdown'; +import DropdownUtils from './dropdown_utils'; +import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; +import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; + +export default class DropdownOperator extends FilteredSearchDropdown { + constructor(options = {}) { + const { input, tokenKeys } = options; + super(options); + + this.config = { + Filter: { + filterFunction: DropdownUtils.filterWithSymbol.bind(null, '', input), + template: 'title', + }, + }; + this.tokenKeys = tokenKeys; + } + + itemClicked(e) { + const { selected } = e.detail; + + if (selected.tagName === 'LI') { + if (selected.hasAttribute('data-value')) { + const operator = selected.dataset.value; + FilteredSearchVisualTokens.removeLastTokenPartial(); + FilteredSearchDropdownManager.addWordToInput({ + tokenName: this.filter, + tokenOperator: operator, + clicked: false, + }); + } + } + this.dismissDropdown(); + this.dispatchInputEvent(); + } + + renderContent(forceShowList = false) { + this.filter = FilteredSearchVisualTokens.getLastTokenPartial(); + + const dropdownData = [ + { + tag: 'equal', + type: 'string', + title: '=', + help: __('Is'), + }, + { + tag: 'not-equal', + type: 'string', + title: '!=', + help: __('Is not'), + }, + ]; + this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config); + this.droplab.setData(this.hookId, dropdownData); + super.renderContent(forceShowList); + } + + init() { + this.droplab.addHook(this.input, this.dropdown, [Filter], this.config).init(); + } +} diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js index 8d92af2cf7e..274c08e6955 100644 --- a/app/assets/javascripts/filtered_search/dropdown_utils.js +++ b/app/assets/javascripts/filtered_search/dropdown_utils.js @@ -62,28 +62,42 @@ export default class DropdownUtils { const lastKey = lastToken.key || lastToken || ''; const allowMultiple = item.type === 'array'; const itemInExistingTokens = tokens.some(t => t.key === item.hint); + const isSearchItem = updatedItem.hint === 'search'; + + if (isSearchItem) { + updatedItem.droplab_hidden = true; + } if (!allowMultiple && itemInExistingTokens) { updatedItem.droplab_hidden = true; - } else if (!lastKey || _.last(searchInput.split('')) === ' ') { + } else if (!isSearchItem && (!lastKey || _.last(searchInput.split('')) === ' ')) { updatedItem.droplab_hidden = false; } else if (lastKey) { const split = lastKey.split(':'); const tokenName = _.last(split[0].split(' ')); - const match = updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1; + const match = isSearchItem + ? allowedKeys.some(key => key.startsWith(tokenName.toLowerCase())) + : updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1; + updatedItem.droplab_hidden = tokenName ? match : false; } return updatedItem; } - static setDataValueIfSelected(filter, selected) { + static setDataValueIfSelected(filter, operator, selected) { const dataValue = selected.getAttribute('data-value'); if (dataValue) { - FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true, { - capitalizeTokenValue: selected.hasAttribute('data-capitalize'), + FilteredSearchDropdownManager.addWordToInput({ + tokenName: filter, + tokenOperator: operator, + tokenValue: dataValue, + clicked: true, + options: { + capitalizeTokenValue: selected.hasAttribute('data-capitalize'), + }, }); } @@ -101,7 +115,11 @@ export default class DropdownUtils { // remove leading symbol and wrapping quotes tokenValue = tokenValue.replace(/^~("|')?(.*)/, '$2').replace(/("|')$/, ''); } - return { tokenName, tokenValue }; + + const operatorEl = visualToken && visualToken.querySelector('.operator'); + const tokenOperator = operatorEl && operatorEl.textContent.trim(); + + return { tokenName, tokenOperator, tokenValue }; } // Determines the full search query (visual tokens + input) @@ -119,10 +137,16 @@ export default class DropdownUtils { tokens.forEach(token => { if (token.classList.contains('js-visual-token')) { const name = token.querySelector('.name'); + const operatorContainer = token.querySelector('.operator'); const value = token.querySelector('.value'); const valueContainer = token.querySelector('.value-container'); const symbol = value && value.dataset.symbol ? value.dataset.symbol : ''; let valueText = ''; + let operator = ''; + + if (operatorContainer) { + operator = operatorContainer.textContent.trim(); + } if (valueContainer && valueContainer.dataset.originalValue) { valueText = valueContainer.dataset.originalValue; @@ -131,7 +155,7 @@ export default class DropdownUtils { } if (token.className.indexOf('filtered-search-token') !== -1) { - values.push(`${name.innerText.toLowerCase()}:${symbol}${valueText}`); + values.push(`${name.innerText.toLowerCase()}:${operator}${symbol}${valueText}`); } else { values.push(name.innerText); } diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js index 146d3ba963c..72565c2ca13 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js @@ -1,5 +1,6 @@ import DropdownUtils from './dropdown_utils'; import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; +import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger'; @@ -31,13 +32,26 @@ export default class FilteredSearchDropdown { itemClicked(e, getValueFunction) { const { selected } = e.detail; - if (selected.tagName === 'LI' && selected.innerHTML) { - const dataValueSet = DropdownUtils.setDataValueIfSelected(this.filter, selected); + const { + lastVisualToken: visualToken, + } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + const { tokenOperator } = DropdownUtils.getVisualTokenValues(visualToken); + + const dataValueSet = DropdownUtils.setDataValueIfSelected( + this.filter, + tokenOperator, + selected, + ); if (!dataValueSet) { const value = getValueFunction(selected); - FilteredSearchDropdownManager.addWordToInput(this.filter, value, true); + FilteredSearchDropdownManager.addWordToInput({ + tokenName: this.filter, + tokenOperator, + tokenValue: value, + clicked: true, + }); } this.resetFilters(); 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 5ff95f45be4..566fb295588 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -5,6 +5,7 @@ import FilteredSearchContainer from './container'; import FilteredSearchTokenKeys from './filtered_search_token_keys'; import DropdownUtils from './dropdown_utils'; import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; +import { DROPDOWN_TYPE } from './constants'; export default class FilteredSearchDropdownManager { constructor({ @@ -67,10 +68,16 @@ export default class FilteredSearchDropdownManager { this.mapping = availableMappings.getAllowedMappings(supportedTokens); } - static addWordToInput(tokenName, tokenValue = '', clicked = false, options = {}) { + static addWordToInput({ + tokenName, + tokenOperator = '', + tokenValue = '', + clicked = false, + options = {}, + }) { const { uppercaseTokenName = false, capitalizeTokenValue = false } = options; const input = FilteredSearchContainer.container.querySelector('.filtered-search'); - FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue, { + FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenOperator, tokenValue, { uppercaseTokenName, capitalizeTokenValue, }); @@ -129,7 +136,10 @@ export default class FilteredSearchDropdownManager { mappingKey.reference.init(); } - if (this.currentDropdown === 'hint') { + if ( + this.currentDropdown === DROPDOWN_TYPE.hint || + this.currentDropdown === DROPDOWN_TYPE.operator + ) { // Force the dropdown to show if it was clicked from the hint dropdown forceShowList = true; } @@ -148,13 +158,19 @@ export default class FilteredSearchDropdownManager { this.droplab = new DropLab(); } + if (dropdownName === DROPDOWN_TYPE.operator) { + this.load(dropdownName, firstLoad); + return; + } + const match = this.filteredSearchTokenKeys.searchByKey(dropdownName.toLowerCase()); const shouldOpenFilterDropdown = match && this.currentDropdown !== match.key && this.mapping[match.key]; - const shouldOpenHintDropdown = !match && this.currentDropdown !== 'hint'; + const shouldOpenHintDropdown = !match && this.currentDropdown !== DROPDOWN_TYPE.hint; if (shouldOpenFilterDropdown || shouldOpenHintDropdown) { - const key = match && match.key ? match.key : 'hint'; + const key = match && match.key ? match.key : DROPDOWN_TYPE.hint; + this.load(key, firstLoad); } } @@ -169,19 +185,32 @@ export default class FilteredSearchDropdownManager { if (this.currentDropdown) { this.updateCurrentDropdownOffset(); } - if (lastToken === searchToken && lastToken !== null) { // Token is not fully initialized yet because it has no value // Eg. token = 'label:' const split = lastToken.split(':'); const dropdownName = _.last(split[0].split(' ')); - this.loadDropdown(split.length > 1 ? dropdownName : ''); + const possibleOperatorToken = _.last(split[1]); + + const hasOperator = FilteredSearchVisualTokens.permissibleOperatorValues.includes( + possibleOperatorToken && possibleOperatorToken.trim(), + ); + + let dropdownToOpen = ''; + + if (split.length > 1) { + const lastOperatorToken = FilteredSearchVisualTokens.getLastTokenOperator(); + dropdownToOpen = hasOperator && lastOperatorToken ? dropdownName : DROPDOWN_TYPE.operator; + } + + this.loadDropdown(dropdownToOpen); } else if (lastToken) { + const lastOperator = FilteredSearchVisualTokens.getLastTokenOperator(); // Token has been initialized into an object because it has a value - this.loadDropdown(lastToken.key); + this.loadDropdown(lastOperator ? lastToken.key : DROPDOWN_TYPE.operator); } else { - this.loadDropdown('hint'); + this.loadDropdown(DROPDOWN_TYPE.hint); } } diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index a4edc5fd52d..0b4f9457c54 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -14,6 +14,7 @@ import FilteredSearchTokenizer from './filtered_search_tokenizer'; import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; import DropdownUtils from './dropdown_utils'; +import { BACKSPACE_KEY_CODE } from '~/lib/utils/keycodes'; import { __ } from '~/locale'; export default class FilteredSearchManager { @@ -58,6 +59,8 @@ export default class FilteredSearchManager { this.recentSearchesService = new RecentSearchesService(recentSearchesKey); } + static notTransformableQueryParams = ['scope', 'utf8', 'state', 'search']; + setup() { // Fetch recent searches from localStorage this.fetchingRecentSearchesPromise = this.recentSearchesService @@ -84,6 +87,7 @@ export default class FilteredSearchManager { if (this.filteredSearchInput) { this.tokenizer = FilteredSearchTokenizer; + this.dropdownManager = new FilteredSearchDropdownManager({ runnerTagsEndpoint: this.filteredSearchInput.getAttribute('data-runner-tags-endpoint') || '', @@ -172,7 +176,7 @@ export default class FilteredSearchManager { this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper); this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper); this.filteredSearchInput.addEventListener('input', this.handleInputPlaceholderWrapper); - this.filteredSearchInput.addEventListener('input', this.handleInputVisualTokenWrapper); + this.filteredSearchInput.addEventListener('keyup', this.handleInputVisualTokenWrapper); this.filteredSearchInput.addEventListener('keydown', this.checkForEnterWrapper); this.filteredSearchInput.addEventListener('keyup', this.checkForBackspaceWrapper); this.filteredSearchInput.addEventListener('click', this.tokenChange); @@ -194,7 +198,7 @@ export default class FilteredSearchManager { this.filteredSearchInput.removeEventListener('input', this.setDropdownWrapper); this.filteredSearchInput.removeEventListener('input', this.toggleClearSearchButtonWrapper); this.filteredSearchInput.removeEventListener('input', this.handleInputPlaceholderWrapper); - this.filteredSearchInput.removeEventListener('input', this.handleInputVisualTokenWrapper); + this.filteredSearchInput.removeEventListener('keyup', this.handleInputVisualTokenWrapper); this.filteredSearchInput.removeEventListener('keydown', this.checkForEnterWrapper); this.filteredSearchInput.removeEventListener('keyup', this.checkForBackspaceWrapper); this.filteredSearchInput.removeEventListener('click', this.tokenChange); @@ -228,7 +232,7 @@ export default class FilteredSearchManager { if (backspaceCount === 2) { backspaceCount = 0; - this.filteredSearchInput.value = FilteredSearchVisualTokens.getLastTokenPartial(); + this.filteredSearchInput.value = FilteredSearchVisualTokens.getLastTokenPartial(true); FilteredSearchVisualTokens.removeLastTokenPartial(); } } @@ -407,7 +411,12 @@ export default class FilteredSearchManager { } } - handleInputVisualToken() { + handleInputVisualToken(e) { + // If the keyCode was 8 then do not form new tokens + if (e.keyCode === BACKSPACE_KEY_CODE) { + return; + } + const input = this.filteredSearchInput; const { tokens, searchToken } = this.tokenizer.processTokens( input.value, @@ -417,14 +426,21 @@ export default class FilteredSearchManager { if (isLastVisualTokenValid) { tokens.forEach(t => { - input.value = input.value.replace(`${t.key}:${t.symbol}${t.value}`, ''); - FilteredSearchVisualTokens.addFilterVisualToken(t.key, `${t.symbol}${t.value}`, { - uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(t.key), - capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(t.key), - }); + input.value = input.value.replace(`${t.key}:${t.operator}${t.symbol}${t.value}`, ''); + + FilteredSearchVisualTokens.addFilterVisualToken( + t.key, + t.operator, + `${t.symbol}${t.value}`, + { + uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(t.key), + capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(t.key), + }, + ); }); const fragments = searchToken.split(':'); + if (fragments.length > 1) { const inputValues = fragments[0].split(' '); const tokenKey = _.last(inputValues); @@ -437,19 +453,58 @@ export default class FilteredSearchManager { FilteredSearchVisualTokens.addSearchVisualToken(searchTerms); } - FilteredSearchVisualTokens.addFilterVisualToken(tokenKey, null, { + FilteredSearchVisualTokens.addFilterVisualToken(tokenKey, null, null, { uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(tokenKey), capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey), }); input.value = input.value.replace(`${tokenKey}:`, ''); } + + const splitSearchToken = searchToken && searchToken.split(' '); + let lastSearchToken = _.last(splitSearchToken); + lastSearchToken = lastSearchToken?.toLowerCase(); + + /** + * If user writes "milestone", a known token, in the input, we should not + * wait for leading colon to flush it as a filter token. + */ + if (this.filteredSearchTokenKeys.getKeys().includes(lastSearchToken)) { + if (splitSearchToken.length > 1) { + splitSearchToken.pop(); + const searchVisualTokens = splitSearchToken.join(' '); + + input.value = input.value.replace(searchVisualTokens, ''); + FilteredSearchVisualTokens.addSearchVisualToken(searchVisualTokens); + } + FilteredSearchVisualTokens.addFilterVisualToken(lastSearchToken, null, null, { + uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName( + lastSearchToken, + ), + capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue( + lastSearchToken, + ), + }); + input.value = input.value.replace(lastSearchToken, ''); + } + } else if (!isLastVisualTokenValid && !FilteredSearchVisualTokens.getLastTokenOperator()) { + const tokenKey = FilteredSearchVisualTokens.getLastTokenPartial(); + const tokenOperator = searchToken && searchToken.trim(); + + // Tokenize operator only if the operator token is valid + if (FilteredSearchVisualTokens.permissibleOperatorValues.includes(tokenOperator)) { + FilteredSearchVisualTokens.removeLastTokenPartial(); + FilteredSearchVisualTokens.addFilterVisualToken(tokenKey, tokenOperator, null, { + capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey), + }); + input.value = input.value.replace(searchToken, '').trim(); + } } else { // Keep listening to token until we determine that the user is done typing the token value const valueCompletedRegex = /([~%@]{0,1}".+")|([~%@]{0,1}'.+')|^((?![~%@]')(?![~%@]")(?!')(?!")).*/g; if (searchToken.match(valueCompletedRegex) && input.value[input.value.length - 1] === ' ') { const tokenKey = FilteredSearchVisualTokens.getLastTokenPartial(); - FilteredSearchVisualTokens.addFilterVisualToken(searchToken, null, { + FilteredSearchVisualTokens.addFilterVisualToken(searchToken, null, null, { capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey), }); @@ -484,9 +539,52 @@ export default class FilteredSearchManager { return this.modifyUrlParams ? this.modifyUrlParams(urlParams) : urlParams; } + transformParams(params) { + /** + * Extract key, value pair from the `not` query param: + * Query param looks like not[key]=value + * + * Eg. not[foo]=%bar + * key = foo; value = %bar + */ + const notKeyValueRegex = new RegExp(/not\[(\w+)\]\[?\]?=(.*)/); + + return params.map(query => { + // Check if there are matches for `not` operator + const matches = query.match(notKeyValueRegex); + if (matches && matches.length === 3) { + const keyParam = matches[1]; + if ( + FilteredSearchManager.notTransformableQueryParams.includes(keyParam) || + this.filteredSearchTokenKeys.searchByConditionUrl(query) + ) { + return query; + } + + const valueParam = matches[2]; + // Not operator + const operator = encodeURIComponent('!='); + return `${keyParam}=${operator}${valueParam}`; + } + + const [keyParam, valueParam] = query.split('='); + + if ( + FilteredSearchManager.notTransformableQueryParams.includes(keyParam) || + this.filteredSearchTokenKeys.searchByConditionUrl(query) + ) { + return query; + } + + const operator = encodeURIComponent('='); + return `${keyParam}=${operator}${valueParam}`; + }); + } + loadSearchParamsFromURL() { const urlParams = getUrlParamsArray(); - const params = this.getAllParams(urlParams); + const withOperatorParams = this.transformParams(urlParams); + const params = this.getAllParams(withOperatorParams); const usernameParams = this.getUsernameParams(); let hasFilteredSearch = false; @@ -501,9 +599,14 @@ export default class FilteredSearchManager { if (condition) { hasFilteredSearch = true; const canEdit = this.canEdit && this.canEdit(condition.tokenKey); - FilteredSearchVisualTokens.addFilterVisualToken(condition.tokenKey, condition.value, { - canEdit, - }); + FilteredSearchVisualTokens.addFilterVisualToken( + condition.tokenKey, + condition.operator, + condition.value, + { + canEdit, + }, + ); } else { // Sanitize value since URL converts spaces into + // Replace before decode so that we know what was originally + versus the encoded + @@ -522,9 +625,12 @@ export default class FilteredSearchManager { hasFilteredSearch = true; const canEdit = this.canEdit && this.canEdit(key, sanitizedValue); const { uppercaseTokenName, capitalizeTokenValue } = match; + const operator = FilteredSearchVisualTokens.getOperatorToken(sanitizedValue); + const sanitizedToken = FilteredSearchVisualTokens.getValueToken(sanitizedValue); FilteredSearchVisualTokens.addFilterVisualToken( key, - `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`, + operator, + `${symbol}${quotationsToUse}${sanitizedToken}${quotationsToUse}`, { canEdit, uppercaseTokenName, @@ -537,7 +643,10 @@ export default class FilteredSearchManager { hasFilteredSearch = true; const tokenName = 'assignee'; const canEdit = this.canEdit && this.canEdit(tokenName); - FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, { + const operator = FilteredSearchVisualTokens.getOperatorToken(usernameParams[id]); + const valueToken = FilteredSearchVisualTokens.getValueToken(usernameParams[id]); + + FilteredSearchVisualTokens.addFilterVisualToken(tokenName, operator, `@${valueToken}`, { canEdit, }); } @@ -547,7 +656,10 @@ export default class FilteredSearchManager { hasFilteredSearch = true; const tokenName = 'author'; const canEdit = this.canEdit && this.canEdit(tokenName); - FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, { + const operator = FilteredSearchVisualTokens.getOperatorToken(usernameParams[id]); + const valueToken = FilteredSearchVisualTokens.getValueToken(usernameParams[id]); + + FilteredSearchVisualTokens.addFilterVisualToken(tokenName, operator, `@${valueToken}`, { canEdit, }); } @@ -582,7 +694,6 @@ export default class FilteredSearchManager { search(state = null) { const paths = []; const searchQuery = DropdownUtils.getSearchQuery(); - this.saveCurrentSearchQuery(); const tokenKeys = this.filteredSearchTokenKeys.getKeys(); @@ -593,6 +704,7 @@ export default class FilteredSearchManager { tokens.forEach(token => { const condition = this.filteredSearchTokenKeys.searchByConditionKeyValue( token.key, + token.operator, token.value, ); const tokenConfig = this.filteredSearchTokenKeys.searchByKey(token.key) || {}; @@ -620,7 +732,16 @@ export default class FilteredSearchManager { tokenValue = tokenValue.slice(1, tokenValue.length - 1); } - tokenPath = `${keyParam}=${encodeURIComponent(tokenValue)}`; + if (token.operator === '!=') { + const isArrayParam = keyParam.endsWith('[]'); + + tokenPath = `not[${isArrayParam ? keyParam.slice(0, -2) : keyParam}]${ + isArrayParam ? '[]' : '' + }=${encodeURIComponent(tokenValue)}`; + } else { + // Default operator is `=` + tokenPath = `${keyParam}=${encodeURIComponent(tokenValue)}`; + } } paths.push(tokenPath); diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js index 0a9579bf491..89fc8047b65 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js @@ -65,17 +65,20 @@ export default class FilteredSearchTokenKeys { return this.conditions.find(condition => condition.url === url) || null; } - searchByConditionKeyValue(key, value) { + searchByConditionKeyValue(key, operator, value) { return ( this.conditions.find( condition => - condition.tokenKey === key && condition.value.toLowerCase() === value.toLowerCase(), + condition.tokenKey === key && + condition.operator === operator && + condition.value.toLowerCase() === value.toLowerCase(), ) || null ); } addExtraTokensForIssues() { const confidentialToken = { + formattedKey: __('Confidential'), key: 'confidential', type: 'string', param: '', diff --git a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js index b5c4cb15aac..963e8fe5df5 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js +++ b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js @@ -2,10 +2,11 @@ import './filtered_search_token_keys'; export default class FilteredSearchTokenizer { static processTokens(input, allowedKeys) { - // Regex extracts `(token):(symbol)(value)` + // Regex extracts `(token):(operator)(symbol)(value)` // Values that start with a double quote must end in a double quote (same for single) + const tokenRegex = new RegExp( - `(${allowedKeys.join('|')}):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, + `(${allowedKeys.join('|')}):(=|!=)?([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, 'g', ); const tokens = []; @@ -13,16 +14,22 @@ export default class FilteredSearchTokenizer { let lastToken = null; const searchToken = input - .replace(tokenRegex, (match, key, symbol, v1, v2, v3) => { + .replace(tokenRegex, (match, key, operator, symbol, v1, v2, v3) => { let tokenValue = v1 || v2 || v3; let tokenSymbol = symbol; let tokenIndex = ''; + let tokenOperator = operator; if (tokenValue === '~' || tokenValue === '%' || tokenValue === '@') { tokenSymbol = tokenValue; tokenValue = ''; } + if (tokenValue === '!=' || tokenValue === '=') { + tokenOperator = tokenValue; + tokenValue = ''; + } + tokenIndex = `${key}:${tokenValue}`; // Prevent adding duplicates @@ -33,6 +40,7 @@ export default class FilteredSearchTokenizer { key, value: tokenValue || '', symbol: tokenSymbol || '', + operator: tokenOperator || '', }); } @@ -43,13 +51,12 @@ export default class FilteredSearchTokenizer { if (tokens.length > 0) { const last = tokens[tokens.length - 1]; - const lastString = `${last.key}:${last.symbol}${last.value}`; + const lastString = `${last.key}:${last.operator}${last.symbol}${last.value}`; lastToken = input.lastIndexOf(lastString) === input.length - lastString.length ? last : searchToken; } else { lastToken = searchToken; } - return { tokens, lastToken, diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index 7f6457242ef..d41d5a543b0 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -3,6 +3,32 @@ import { objectToQueryString } from '~/lib/utils/common_utils'; import FilteredSearchContainer from './container'; export default class FilteredSearchVisualTokens { + static permissibleOperatorValues = ['=', '!=']; + + static getOperatorToken(value) { + let token = null; + + FilteredSearchVisualTokens.permissibleOperatorValues.forEach(operatorToken => { + if (value.startsWith(operatorToken)) { + token = operatorToken; + } + }); + + return token; + } + + static getValueToken(value) { + let newValue = value; + + FilteredSearchVisualTokens.permissibleOperatorValues.forEach(operatorToken => { + if (value.startsWith(operatorToken)) { + newValue = value.slice(operatorToken.length); + } + }); + + return newValue; + } + static getLastVisualTokenBeforeInput() { const inputLi = FilteredSearchContainer.container.querySelector('.input-token'); const lastVisualToken = inputLi && inputLi.previousElementSibling; @@ -12,7 +38,9 @@ export default class FilteredSearchVisualTokens { isLastVisualTokenValid: lastVisualToken === null || lastVisualToken.className.indexOf('filtered-search-term') !== -1 || - (lastVisualToken && lastVisualToken.querySelector('.value') !== null), + (lastVisualToken && + lastVisualToken.querySelector('.operator') !== null && + lastVisualToken.querySelector('.value') !== null), }; } @@ -42,11 +70,17 @@ export default class FilteredSearchVisualTokens { } static createVisualTokenElementHTML(options = {}) { - const { canEdit = true, uppercaseTokenName = false, capitalizeTokenValue = false } = options; + const { + canEdit = true, + hasOperator = false, + uppercaseTokenName = false, + capitalizeTokenValue = false, + } = options; return ` <div class="${canEdit ? 'selectable' : 'hidden'}" role="button"> <div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div> + ${hasOperator ? '<div class="operator"></div>' : ''} <div class="value-container"> <div class="${capitalizeTokenValue ? 'text-capitalize' : ''} value"></div> <div class="remove-token" role="button"> @@ -57,18 +91,18 @@ export default class FilteredSearchVisualTokens { `; } - static renderVisualTokenValue(parentElement, tokenName, tokenValue) { + static renderVisualTokenValue(parentElement, tokenName, tokenValue, tokenOperator) { const tokenType = tokenName.toLowerCase(); const tokenValueContainer = parentElement.querySelector('.value-container'); const tokenValueElement = tokenValueContainer.querySelector('.value'); tokenValueElement.innerText = tokenValue; - const visualTokenValue = new VisualTokenValue(tokenValue, tokenType); + const visualTokenValue = new VisualTokenValue(tokenValue, tokenType, tokenOperator); visualTokenValue.render(tokenValueContainer, tokenValueElement); } - static addVisualTokenElement(name, value, options = {}) { + static addVisualTokenElement({ name, operator, value, options = {} }) { const { isSearchTerm = false, canEdit, @@ -84,17 +118,32 @@ export default class FilteredSearchVisualTokens { li.classList.add(tokenClass); } + const hasOperator = Boolean(operator); + if (value) { li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({ canEdit, uppercaseTokenName, + operator, + hasOperator, capitalizeTokenValue, }); - FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value); + FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value, operator); } else { - li.innerHTML = `<div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div>`; + const nameHTML = `<div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div>`; + let operatorHTML = ''; + + if (hasOperator) { + operatorHTML = '<div class="operator"></div>'; + } + + li.innerHTML = nameHTML + operatorHTML; } + li.querySelector('.name').innerText = name; + if (hasOperator) { + li.querySelector('.operator').innerText = operator; + } const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container'); const input = FilteredSearchContainer.container.querySelector('.filtered-search'); @@ -109,14 +158,19 @@ export default class FilteredSearchVisualTokens { if (!isLastVisualTokenValid && lastVisualToken.classList.contains('filtered-search-token')) { const name = FilteredSearchVisualTokens.getLastTokenPartial(); - lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(); + const operator = FilteredSearchVisualTokens.getLastTokenOperator(); + lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({ + hasOperator: Boolean(operator), + }); lastVisualToken.querySelector('.name').innerText = name; - FilteredSearchVisualTokens.renderVisualTokenValue(lastVisualToken, name, value); + lastVisualToken.querySelector('.operator').innerText = operator; + FilteredSearchVisualTokens.renderVisualTokenValue(lastVisualToken, name, value, operator); } } static addFilterVisualToken( tokenName, + tokenOperator, tokenValue, { canEdit, uppercaseTokenName = false, capitalizeTokenValue = false } = {}, ) { @@ -127,21 +181,51 @@ export default class FilteredSearchVisualTokens { const { addVisualTokenElement } = FilteredSearchVisualTokens; if (isLastVisualTokenValid) { - addVisualTokenElement(tokenName, tokenValue, { - canEdit, - uppercaseTokenName, - capitalizeTokenValue, + addVisualTokenElement({ + name: tokenName, + operator: tokenOperator, + value: tokenValue, + options: { + canEdit, + uppercaseTokenName, + capitalizeTokenValue, + }, + }); + } else if ( + !isLastVisualTokenValid && + (lastVisualToken && !lastVisualToken.querySelector('.operator')) + ) { + const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container'); + tokensContainer.removeChild(lastVisualToken); + addVisualTokenElement({ + name: tokenName, + operator: tokenOperator, + value: tokenValue, + options: { + canEdit, + uppercaseTokenName, + capitalizeTokenValue, + }, }); } else { const previousTokenName = lastVisualToken.querySelector('.name').innerText; + const previousTokenOperator = lastVisualToken.querySelector('.operator').innerText; const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container'); tokensContainer.removeChild(lastVisualToken); - const value = tokenValue || tokenName; - addVisualTokenElement(previousTokenName, value, { - canEdit, - uppercaseTokenName, - capitalizeTokenValue, + let value = tokenValue; + if (!value && !tokenOperator) { + value = tokenName; + } + addVisualTokenElement({ + name: previousTokenName, + operator: previousTokenOperator, + value, + options: { + canEdit, + uppercaseTokenName, + capitalizeTokenValue, + }, }); } } @@ -152,13 +236,18 @@ export default class FilteredSearchVisualTokens { if (lastVisualToken && lastVisualToken.classList.contains('filtered-search-term')) { lastVisualToken.querySelector('.name').innerText += ` ${searchTerm}`; } else { - FilteredSearchVisualTokens.addVisualTokenElement(searchTerm, null, { - isSearchTerm: true, + FilteredSearchVisualTokens.addVisualTokenElement({ + name: searchTerm, + operator: null, + value: null, + options: { + isSearchTerm: true, + }, }); } } - static getLastTokenPartial() { + static getLastTokenPartial(includeOperator = false) { const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); if (!lastVisualToken) return ''; @@ -175,20 +264,36 @@ export default class FilteredSearchVisualTokens { const valueText = value ? value.innerText : ''; const nameText = name ? name.innerText : ''; + if (includeOperator) { + const operator = lastVisualToken.querySelector('.operator'); + const operatorText = operator ? operator.innerText : ''; + return valueText || operatorText || nameText; + } + return valueText || nameText; } + static getLastTokenOperator() { + const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + + const operator = lastVisualToken && lastVisualToken.querySelector('.operator'); + + return operator?.innerText; + } + static removeLastTokenPartial() { const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); if (lastVisualToken) { const value = lastVisualToken.querySelector('.value'); - + const operator = lastVisualToken.querySelector('.operator'); if (value) { const button = lastVisualToken.querySelector('.selectable'); const valueContainer = lastVisualToken.querySelector('.value-container'); button.removeChild(valueContainer); lastVisualToken.innerHTML = button.innerHTML; + } else if (operator) { + lastVisualToken.removeChild(operator); } else { lastVisualToken.closest('.tokens-container').removeChild(lastVisualToken); } @@ -236,12 +341,18 @@ export default class FilteredSearchVisualTokens { tokenContainer.replaceChild(inputLi, token); const nameElement = token.querySelector('.name'); + const operatorElement = token.querySelector('.operator'); let value; if (token.classList.contains('filtered-search-token')) { - FilteredSearchVisualTokens.addFilterVisualToken(nameElement.innerText, null, { - uppercaseTokenName: nameElement.classList.contains('text-uppercase'), - }); + FilteredSearchVisualTokens.addFilterVisualToken( + nameElement.innerText, + operatorElement.innerText, + null, + { + uppercaseTokenName: nameElement.classList.contains('text-uppercase'), + }, + ); const valueContainerElement = token.querySelector('.value-container'); value = valueContainerElement.dataset.originalValue; diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js index 414bcf186a3..8722fc64b62 100644 --- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js @@ -1,10 +1,10 @@ +import { flatten } from 'underscore'; import FilteredSearchTokenKeys from './filtered_search_token_keys'; import { __ } from '~/locale'; -export const tokenKeys = []; - -tokenKeys.push( +export const tokenKeys = [ { + formattedKey: __('Author'), key: 'author', type: 'string', param: 'username', @@ -13,6 +13,7 @@ tokenKeys.push( tag: '@author', }, { + formattedKey: __('Assignee'), key: 'assignee', type: 'string', param: 'username', @@ -21,6 +22,7 @@ tokenKeys.push( tag: '@assignee', }, { + formattedKey: __('Milestone'), key: 'milestone', type: 'string', param: 'title', @@ -28,31 +30,30 @@ tokenKeys.push( icon: 'clock', tag: '%milestone', }, -); - -if (gon && gon.features && gon.features.releaseSearchFilter) { - tokenKeys.push({ + { + formattedKey: __('Release'), key: 'release', type: 'string', param: 'tag', symbol: '', icon: 'rocket', tag: __('tag name'), - }); -} - -tokenKeys.push({ - key: 'label', - type: 'array', - param: 'name[]', - symbol: '~', - icon: 'labels', - tag: '~label', -}); + }, + { + formattedKey: __('Label'), + key: 'label', + type: 'array', + param: 'name[]', + symbol: '~', + icon: 'labels', + tag: '~label', + }, +]; if (gon.current_user_id) { // Appending tokenkeys only logged-in tokenKeys.push({ + formattedKey: __('My-Reaction'), key: 'my-reaction', type: 'string', param: 'emoji', @@ -64,6 +65,7 @@ if (gon.current_user_id) { export const alternativeTokenKeys = [ { + formattedKey: __('Label'), key: 'label', type: 'string', param: 'name', @@ -71,68 +73,88 @@ export const alternativeTokenKeys = [ }, ]; -export const conditions = [ - { - url: 'assignee_id=None', - tokenKey: 'assignee', - value: __('None'), - }, - { - url: 'assignee_id=Any', - tokenKey: 'assignee', - value: __('Any'), - }, - { - url: 'milestone_title=None', - tokenKey: 'milestone', - value: __('None'), - }, - { - url: 'milestone_title=Any', - tokenKey: 'milestone', - value: __('Any'), - }, - { - url: 'milestone_title=%23upcoming', - tokenKey: 'milestone', - value: __('Upcoming'), - }, - { - url: 'milestone_title=%23started', - tokenKey: 'milestone', - value: __('Started'), - }, - { - url: 'release_tag=None', - tokenKey: 'release', - value: __('None'), - }, - { - url: 'release_tag=Any', - tokenKey: 'release', - value: __('Any'), - }, - { - url: 'label_name[]=None', - tokenKey: 'label', - value: __('None'), - }, - { - url: 'label_name[]=Any', - tokenKey: 'label', - value: __('Any'), - }, - { - url: 'my_reaction_emoji=None', - tokenKey: 'my-reaction', - value: __('None'), - }, - { - url: 'my_reaction_emoji=Any', - tokenKey: 'my-reaction', - value: __('Any'), - }, -]; +export const conditions = flatten( + [ + { + url: 'assignee_id=None', + tokenKey: 'assignee', + value: __('None'), + }, + { + url: 'assignee_id=Any', + tokenKey: 'assignee', + value: __('Any'), + }, + { + url: 'milestone_title=None', + tokenKey: 'milestone', + value: __('None'), + }, + { + url: 'milestone_title=Any', + tokenKey: 'milestone', + value: __('Any'), + }, + { + url: 'milestone_title=%23upcoming', + tokenKey: 'milestone', + value: __('Upcoming'), + }, + { + url: 'milestone_title=%23started', + tokenKey: 'milestone', + value: __('Started'), + }, + { + url: 'release_tag=None', + tokenKey: 'release', + value: __('None'), + }, + { + url: 'release_tag=Any', + tokenKey: 'release', + value: __('Any'), + }, + { + url: 'label_name[]=None', + tokenKey: 'label', + value: __('None'), + }, + { + url: 'label_name[]=Any', + tokenKey: 'label', + value: __('Any'), + }, + { + url: 'my_reaction_emoji=None', + tokenKey: 'my-reaction', + value: __('None'), + }, + { + url: 'my_reaction_emoji=Any', + tokenKey: 'my-reaction', + value: __('Any'), + }, + ].map(condition => { + const [keyPart, valuePart] = condition.url.split('='); + const hasBrackets = keyPart.includes('[]'); + + const notEqualUrl = `not[${hasBrackets ? keyPart.slice(0, -2) : keyPart}]${ + hasBrackets ? '[]' : '' + }=${valuePart}`; + return [ + { + ...condition, + operator: '=', + }, + { + ...condition, + operator: '!=', + url: notEqualUrl, + }, + ]; + }), +); const IssuableFilteredSearchTokenKeys = new FilteredSearchTokenKeys( tokenKeys, diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js index 1343ccd6468..9f3cf881af4 100644 --- a/app/assets/javascripts/filtered_search/visual_token_value.js +++ b/app/assets/javascripts/filtered_search/visual_token_value.js @@ -9,9 +9,10 @@ import UsersCache from '~/lib/utils/users_cache'; import { __ } from '~/locale'; export default class VisualTokenValue { - constructor(tokenValue, tokenType) { + constructor(tokenValue, tokenType, tokenOperator) { this.tokenValue = tokenValue; this.tokenType = tokenType; + this.tokenOperator = tokenOperator; } render(tokenValueContainer, tokenValueElement) { diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js index 2566ed6b47c..b9ce0851585 100644 --- a/app/assets/javascripts/fly_out_nav.js +++ b/app/assets/javascripts/fly_out_nav.js @@ -1,4 +1,4 @@ -import bp from './breakpoints'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { SIDEBAR_COLLAPSED_CLASS } from './contextual_sidebar'; const HIDE_INTERVAL_TIMEOUT = 300; @@ -40,10 +40,7 @@ export const canShowActiveSubItems = el => { return true; }; -export const canShowSubItems = () => - bp.getBreakpointSize() === 'sm' || - bp.getBreakpointSize() === 'md' || - bp.getBreakpointSize() === 'lg'; +export const canShowSubItems = () => ['md', 'lg', 'xl'].includes(bp.getBreakpointSize()); export const getHideSubItemsInterval = () => { if (!currentOpenMenu || !mousePos.length) return 0; diff --git a/app/assets/javascripts/frequent_items/utils.js b/app/assets/javascripts/frequent_items/utils.js index aba692e4b99..cc1668b1a0d 100644 --- a/app/assets/javascripts/frequent_items/utils.js +++ b/app/assets/javascripts/frequent_items/utils.js @@ -1,12 +1,8 @@ import _ from 'underscore'; -import bp from '~/breakpoints'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { FREQUENT_ITEMS, HOUR_IN_MS } from './constants'; -export const isMobile = () => { - const screenSize = bp.getBreakpointSize(); - - return screenSize === 'sm' || screenSize === 'xs'; -}; +export const isMobile = () => ['md', 'sm', 'xs'].includes(bp.getBreakpointSize()); export const getTopFrequentItems = items => { if (!items) { diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index e25c9d90f60..de69daf5c22 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -107,8 +107,13 @@ class GfmAutoComplete { if (value.params.length > 0) { tpl += ' <small class="params"><%- params.join(" ") %></small>'; } - if (value.description !== '') { - tpl += '<small class="description"><i><%- description %> <%- warningText %></i></small>'; + if (value.warning && value.icon && value.icon === 'confidential') { + tpl += + '<small class="description"><em><i class="fa fa-eye-slash" aria-hidden="true"/><%- warning %></em></small>'; + } else if (value.warning) { + tpl += '<small class="description"><em><%- warning %></em></small>'; + } else if (value.description !== '') { + tpl += '<small class="description"><em><%- description %></em></small>'; } tpl += '</li>'; @@ -119,7 +124,6 @@ class GfmAutoComplete { return _.template(tpl)({ ...value, className: cssClasses.join(' '), - warningText: value.warning ? `(${value.warning})` : '', }); }, insertTpl(value) { @@ -150,6 +154,7 @@ class GfmAutoComplete { params: c.params, description: c.description, warning: c.warning, + icon: c.icon, search, }; }); diff --git a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue index 6258ee7f153..41d83e45c52 100644 --- a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue +++ b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlFormGroup, GlFormInput, GlFormCheckbox, GlLink } from '@gitlab/ui'; +import { GlButton, GlFormGroup, GlFormInput, GlFormCheckbox } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; @@ -9,7 +9,6 @@ export default { GlFormCheckbox, GlFormGroup, GlFormInput, - GlLink, Icon, }, data() { diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue index 675552e6c2b..53da3f7b2ee 100644 --- a/app/assets/javascripts/groups/components/item_stats.vue +++ b/app/assets/javascripts/groups/components/item_stats.vue @@ -1,5 +1,4 @@ <script> -import icon from '~/vue_shared/components/icon.vue'; import { GlBadge } from '@gitlab/ui'; import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { @@ -13,7 +12,6 @@ import isProjectPendingRemoval from 'ee_else_ce/groups/mixins/is_project_pending export default { components: { - icon, timeAgoTooltip, itemStatsValue, GlBadge, diff --git a/app/assets/javascripts/helpers/diffs_helper.js b/app/assets/javascripts/helpers/diffs_helper.js new file mode 100644 index 00000000000..9695d01ad3d --- /dev/null +++ b/app/assets/javascripts/helpers/diffs_helper.js @@ -0,0 +1,19 @@ +export function hasInlineLines(diffFile) { + return diffFile?.highlighted_diff_lines?.length > 0; /* eslint-disable-line camelcase */ +} + +export function hasParallelLines(diffFile) { + return diffFile?.parallel_diff_lines?.length > 0; /* eslint-disable-line camelcase */ +} + +export function isSingleViewStyle(diffFile) { + return !hasParallelLines(diffFile) || !hasInlineLines(diffFile); +} + +export function hasDiff(diffFile) { + return ( + hasInlineLines(diffFile) || + hasParallelLines(diffFile) || + !diffFile?.blob?.readable_text /* eslint-disable-line camelcase */ + ); +} diff --git a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue index 6b2ef34c960..3398cd091ba 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue @@ -1,12 +1,13 @@ <script> -import $ from 'jquery'; import { mapActions } from 'vuex'; -import { __ } from '~/locale'; +import { sprintf, __ } from '~/locale'; +import { GlModal } from '@gitlab/ui'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; export default { components: { + GlModal, FileIcon, ChangedFileIcon, }, @@ -17,7 +18,13 @@ export default { }, }, computed: { - activeButtonText() { + discardModalId() { + return `discard-file-${this.activeFile.path}`; + }, + discardModalTitle() { + return sprintf(__('Discard changes to %{path}?'), { path: this.activeFile.path }); + }, + actionButtonText() { return this.activeFile.staged ? __('Unstage') : __('Stage'); }, isStaged() { @@ -25,7 +32,7 @@ export default { }, }, methods: { - ...mapActions(['stageChange', 'unstageChange']), + ...mapActions(['stageChange', 'unstageChange', 'discardFileChanges']), actionButtonClicked() { if (this.activeFile.staged) { this.unstageChange(this.activeFile.path); @@ -34,7 +41,7 @@ export default { } }, showDiscardModal() { - $(document.getElementById(`discard-file-${this.activeFile.path}`)).modal('show'); + this.$refs.discardModal.show(); }, }, }; @@ -53,6 +60,7 @@ export default { <div class="ml-auto"> <button v-if="!isStaged" + ref="discardButton" type="button" class="btn btn-remove btn-inverted append-right-8" @click="showDiscardModal" @@ -60,6 +68,7 @@ export default { {{ __('Discard') }} </button> <button + ref="actionButton" :class="{ 'btn-success': !isStaged, 'btn-warning': isStaged, @@ -68,8 +77,19 @@ export default { class="btn btn-inverted" @click="actionButtonClicked" > - {{ activeButtonText }} + {{ actionButtonText }} </button> </div> + <gl-modal + ref="discardModal" + ok-variant="danger" + cancel-variant="light" + :ok-title="__('Discard changes')" + :modal-id="discardModalId" + :title="discardModalTitle" + @ok="discardFileChanges(activeFile.path)" + > + {{ __("You will lose all changes you've made to this file. This action cannot be undone.") }} + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue index f7ed7006874..9d5473a1201 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -6,6 +6,7 @@ import CommitMessageField from './message_field.vue'; import Actions from './actions.vue'; import SuccessMessage from './success_message.vue'; import { activityBarViews, MAX_WINDOW_HEIGHT_COMPACT } from '../../constants'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { @@ -14,6 +15,7 @@ export default { CommitMessageField, SuccessMessage, }, + mixins: [glFeatureFlagsMixin()], data() { return { isCompact: true, @@ -27,9 +29,13 @@ export default { ...mapGetters('commit', ['discardDraftButtonDisabled', 'preBuiltCommitMessage']), overviewText() { return sprintf( - __( - '<strong>%{changedFilesLength} unstaged</strong> and <strong>%{stagedFilesLength} staged</strong> changes', - ), + this.glFeatures.stageAllByDefault + ? __( + '<strong>%{stagedFilesLength} staged</strong> and <strong>%{changedFilesLength} unstaged</strong> changes', + ) + : __( + '<strong>%{changedFilesLength} unstaged</strong> and <strong>%{stagedFilesLength} staged</strong> changes', + ), { stagedFilesLength: this.stagedFiles.length, changedFilesLength: this.changedFiles.length, @@ -39,6 +45,10 @@ export default { commitButtonText() { return this.stagedFiles.length ? __('Commit') : __('Stage & Commit'); }, + + currentViewIsCommitView() { + return this.currentActivityView === activityBarViews.commit; + }, }, watch: { currentActivityView() { @@ -46,11 +56,11 @@ export default { this.isCompact = false; } else { this.isCompact = !( - this.currentActivityView === activityBarViews.commit && - window.innerHeight >= MAX_WINDOW_HEIGHT_COMPACT + this.currentViewIsCommitView && window.innerHeight >= MAX_WINDOW_HEIGHT_COMPACT ); } }, + lastCommitMsg() { this.isCompact = this.currentActivityView !== activityBarViews.commit && this.lastCommitMsg === ''; @@ -59,14 +69,18 @@ export default { methods: { ...mapActions(['updateActivityBarView']), ...mapActions('commit', ['updateCommitMessage', 'discardDraft', 'commitChanges']), - toggleIsSmall() { - this.updateActivityBarView(activityBarViews.commit) - .then(() => { - this.isCompact = !this.isCompact; - }) - .catch(e => { - throw e; - }); + toggleIsCompact() { + if (this.currentViewIsCommitView) { + this.isCompact = !this.isCompact; + } else { + this.updateActivityBarView(activityBarViews.commit) + .then(() => { + this.isCompact = false; + }) + .catch(e => { + throw e; + }); + } }, beforeEnterTransition() { const elHeight = this.isCompact @@ -114,7 +128,7 @@ export default { :disabled="!hasChanges" type="button" class="btn btn-primary btn-sm btn-block qa-begin-commit-button" - @click="toggleIsSmall" + @click="toggleIsCompact" > {{ __('Commitā¦') }} </button> @@ -148,7 +162,7 @@ export default { v-else type="button" class="btn btn-default btn-sm float-right" - @click="toggleIsSmall" + @click="toggleIsCompact" > {{ __('Collapse') }} </button> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue index e16918ae025..d9a385a9d31 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -41,10 +41,6 @@ export default { type: String, required: true, }, - itemActionComponent: { - type: String, - required: true, - }, stagedList: { type: Boolean, required: false, @@ -142,7 +138,6 @@ export default { <li v-for="file in fileList" :key="file.key"> <list-item :file="file" - :action-component="itemActionComponent" :key-prefix="keyPrefix" :staged-list="stagedList" :active-file-key="activeFileKey" 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 230dfaf047b..726e2b7e1fc 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -3,16 +3,12 @@ import { mapActions } from 'vuex'; import tooltip from '~/vue_shared/directives/tooltip'; import Icon from '~/vue_shared/components/icon.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; -import StageButton from './stage_button.vue'; -import UnstageButton from './unstage_button.vue'; import { viewerTypes } from '../../constants'; import { getCommitIconMap } from '../../utils'; export default { components: { Icon, - StageButton, - UnstageButton, FileIcon, }, directives: { @@ -23,10 +19,6 @@ export default { type: Object, required: true, }, - actionComponent: { - type: String, - required: true, - }, keyPrefix: { type: String, required: false, diff --git a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue deleted file mode 100644 index c14b8a47841..00000000000 --- a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue +++ /dev/null @@ -1,78 +0,0 @@ -<script> -import $ from 'jquery'; -import { mapActions } from 'vuex'; -import { sprintf, __ } from '~/locale'; -import Icon from '~/vue_shared/components/icon.vue'; -import tooltip from '~/vue_shared/directives/tooltip'; -import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; - -export default { - components: { - Icon, - GlModal: DeprecatedModal2, - }, - directives: { - tooltip, - }, - props: { - path: { - type: String, - required: true, - }, - }, - computed: { - modalId() { - return `discard-file-${this.path}`; - }, - modalTitle() { - return sprintf(__('Discard changes to %{path}?'), { path: this.path }); - }, - }, - methods: { - ...mapActions(['stageChange', 'discardFileChanges']), - showDiscardModal() { - $(document.getElementById(this.modalId)).modal('show'); - }, - }, -}; -</script> - -<template> - <div v-once class="multi-file-discard-btn d-flex"> - <button - v-tooltip - :aria-label="__('Stage changes')" - :title="__('Stage changes')" - type="button" - class="btn btn-blank align-items-center" - data-container="body" - data-boundary="viewport" - data-placement="bottom" - @click.stop.prevent="stageChange(path)" - > - <icon :size="16" name="mobile-issue-close" class="ml-auto mr-auto" /> - </button> - <button - v-tooltip - :aria-label="__('Discard changes')" - :title="__('Discard changes')" - type="button" - class="btn btn-blank align-items-center" - data-container="body" - data-boundary="viewport" - data-placement="bottom" - @click.stop.prevent="showDiscardModal" - > - <icon :size="16" name="remove" class="ml-auto mr-auto" /> - </button> - <gl-modal - :id="modalId" - :header-title-text="modalTitle" - :footer-primary-button-text="__('Discard changes')" - footer-primary-button-variant="danger" - @submit="discardFileChanges(path)" - > - {{ __("You will lose all changes you've made to this file. This action cannot be undone.") }} - </gl-modal> - </div> -</template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue deleted file mode 100644 index 0567ef54ff3..00000000000 --- a/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue +++ /dev/null @@ -1,41 +0,0 @@ -<script> -import { mapActions } from 'vuex'; -import Icon from '~/vue_shared/components/icon.vue'; -import tooltip from '~/vue_shared/directives/tooltip'; - -export default { - components: { - Icon, - }, - directives: { - tooltip, - }, - props: { - path: { - type: String, - required: true, - }, - }, - methods: { - ...mapActions(['unstageChange']), - }, -}; -</script> - -<template> - <div v-once class="multi-file-discard-btn d-flex"> - <button - v-tooltip - :aria-label="__('Unstage changes')" - :title="__('Unstage changes')" - type="button" - class="btn btn-blank align-items-center" - data-container="body" - data-boundary="viewport" - data-placement="bottom" - @click.stop.prevent="unstageChange(path)" - > - <icon :size="16" name="redo" class="ml-auto mr-auto" /> - </button> - </div> -</template> diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue index f0bedcfbd6b..33098eb1af0 100644 --- a/app/assets/javascripts/ide/components/file_row_extra.vue +++ b/app/assets/javascripts/ide/components/file_row_extra.vue @@ -6,6 +6,7 @@ import Icon from '~/vue_shared/components/icon.vue'; import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; import NewDropdown from './new_dropdown/index.vue'; import MrFileIcon from './mr_file_icon.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'FileRowExtra', @@ -18,6 +19,7 @@ export default { ChangedFileIcon, MrFileIcon, }, + mixins: [glFeatureFlagsMixin()], props: { file: { type: Object, @@ -55,10 +57,15 @@ export default { return n__('%d staged change', '%d staged changes', this.folderStagedCount); } - return sprintf(__('%{unstaged} unstaged and %{staged} staged changes'), { - unstaged: this.folderUnstagedCount, - staged: this.folderStagedCount, - }); + return sprintf( + this.glFeatures.stageAllByDefault + ? __('%{staged} staged and %{unstaged} unstaged changes') + : __('%{unstaged} unstaged and %{staged} staged changes'), + { + unstaged: this.folderUnstagedCount, + staged: this.folderStagedCount, + }, + ); }, showTreeChangesCount() { return this.isTree && this.changesCount > 0 && !this.file.opened; diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 363a8f43033..6ed863c9c2e 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -1,6 +1,6 @@ <script> import Vue from 'vue'; -import { mapActions, mapState, mapGetters } from 'vuex'; +import { mapActions, mapGetters, mapState } from 'vuex'; import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import FindFile from '~/vue_shared/components/file_finder/index.vue'; diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue index f93496132a4..598f3a1dac6 100644 --- a/app/assets/javascripts/ide/components/ide_tree.vue +++ b/app/assets/javascripts/ide/components/ide_tree.vue @@ -1,13 +1,11 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; -import Icon from '~/vue_shared/components/icon.vue'; import IdeTreeList from './ide_tree_list.vue'; import Upload from './new_dropdown/upload.vue'; import NewEntryButton from './new_dropdown/button.vue'; export default { components: { - Icon, Upload, IdeTreeList, NewEntryButton, diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue index 3a0dd60f0e0..bacdfc7c05e 100644 --- a/app/assets/javascripts/ide/components/ide_tree_list.vue +++ b/app/assets/javascripts/ide/components/ide_tree_list.vue @@ -1,14 +1,12 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import { GlSkeletonLoading } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; import FileRow from '~/vue_shared/components/file_row.vue'; import NavDropdown from './nav_dropdown.vue'; import FileRowExtra from './file_row_extra.vue'; export default { components: { - Icon, GlSkeletonLoading, NavDropdown, FileRow, diff --git a/app/assets/javascripts/ide/components/nav_dropdown.vue b/app/assets/javascripts/ide/components/nav_dropdown.vue index e45d2a62dae..2e290de0943 100644 --- a/app/assets/javascripts/ide/components/nav_dropdown.vue +++ b/app/assets/javascripts/ide/components/nav_dropdown.vue @@ -1,12 +1,10 @@ <script> import $ from 'jquery'; -import Icon from '~/vue_shared/components/icon.vue'; import NavForm from './nav_form.vue'; import NavDropdownButton from './nav_dropdown_button.vue'; export default { components: { - Icon, NavDropdownButton, NavForm, }, diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index ecafb4e81c4..bf3d736ddf3 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -67,8 +67,8 @@ export default { if (this.entryModal.type === modalTypes.rename) { if (this.entries[this.entryName] && !this.entries[this.entryName].deleted) { flash( - sprintf(s__('The name %{entryName} is already taken in this directory.'), { - entryName: this.entryName, + sprintf(s__('The name "%{name}" is already taken in this directory.'), { + name: this.entryName, }), 'alert', document, @@ -81,22 +81,11 @@ export default { const entryName = parentPath.pop(); parentPath = parentPath.join('/'); - const createPromise = - parentPath && !this.entries[parentPath] - ? this.createTempEntry({ name: parentPath, type: 'tree' }) - : Promise.resolve(); - - createPromise - .then(() => - this.renameEntry({ - path: this.entryModal.entry.path, - name: entryName, - parentPath, - }), - ) - .catch(() => - flash(__('Error creating a new path'), 'alert', document, null, false, true), - ); + this.renameEntry({ + path: this.entryModal.entry.path, + name: entryName, + parentPath, + }); } } else { this.createTempEntry({ diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue index 188518dd419..e52613086a4 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue @@ -1,10 +1,8 @@ <script> -import Icon from '~/vue_shared/components/icon.vue'; import ItemButton from './button.vue'; export default { components: { - Icon, ItemButton, }, props: { diff --git a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue new file mode 100644 index 00000000000..d5a123edb80 --- /dev/null +++ b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue @@ -0,0 +1,151 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import _ from 'underscore'; +import tooltip from '~/vue_shared/directives/tooltip'; +import Icon from '~/vue_shared/components/icon.vue'; +import ResizablePanel from '../resizable_panel.vue'; +import { GlSkeletonLoading } from '@gitlab/ui'; + +export default { + name: 'CollapsibleSidebar', + directives: { + tooltip, + }, + components: { + Icon, + ResizablePanel, + GlSkeletonLoading, + }, + props: { + extensionTabs: { + type: Array, + required: false, + default: () => [], + }, + side: { + type: String, + required: true, + }, + width: { + type: Number, + required: true, + }, + }, + computed: { + ...mapState(['loading']), + ...mapState({ + isOpen(state) { + return state[this.namespace].isOpen; + }, + currentView(state) { + return state[this.namespace].currentView; + }, + isActiveView(state, getters) { + return getters[`${this.namespace}/isActiveView`]; + }, + isAliveView(_state, getters) { + return getters[`${this.namespace}/isAliveView`]; + }, + }), + namespace() { + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings + return `${this.side}Pane`; + }, + tabs() { + return this.extensionTabs.filter(tab => tab.show); + }, + tabViews() { + return _.flatten(this.tabs.map(tab => tab.views)); + }, + aliveTabViews() { + return this.tabViews.filter(view => this.isAliveView(view.name)); + }, + otherSide() { + return this.side === 'right' ? 'left' : 'right'; + }, + }, + methods: { + ...mapActions({ + toggleOpen(dispatch) { + return dispatch(`${this.namespace}/toggleOpen`); + }, + open(dispatch, view) { + return dispatch(`${this.namespace}/open`, view); + }, + }), + clickTab(e, tab) { + e.target.blur(); + + if (this.isActiveTab(tab)) { + this.toggleOpen(); + } else { + this.open(tab.views[0]); + } + }, + isActiveTab(tab) { + return tab.views.some(view => this.isActiveView(view.name)); + }, + buttonClasses(tab) { + return [ + this.side === 'right' ? 'is-right' : '', + this.isActiveTab(tab) && this.isOpen ? 'active' : '', + ...(tab.buttonClasses || []), + ]; + }, + }, +}; +</script> + +<template> + <div + :class="`ide-${side}-sidebar`" + :data-qa-selector="`ide_${side}_sidebar`" + class="multi-file-commit-panel ide-sidebar" + > + <resizable-panel + v-show="isOpen" + :collapsible="false" + :initial-width="width" + :min-size="width" + :class="`ide-${side}-sidebar-${currentView}`" + :side="side" + class="multi-file-commit-panel-inner" + > + <div class="h-100 d-flex flex-column align-items-stretch"> + <slot v-if="isOpen" name="header"></slot> + <div + v-for="tabView in aliveTabViews" + v-show="isActiveView(tabView.name)" + :key="tabView.name" + class="flex-fill js-tab-view" + > + <component :is="tabView.component" /> + </div> + <slot name="footer"></slot> + </div> + </resizable-panel> + <nav class="ide-activity-bar"> + <ul class="list-unstyled"> + <li> + <slot name="header-icon"></slot> + </li> + <li v-for="tab of tabs" :key="tab.title"> + <button + v-tooltip + :title="tab.title" + :aria-label="tab.title" + :class="buttonClasses(tab)" + data-container="body" + :data-placement="otherSide" + :data-qa-selector="`${tab.title.toLowerCase()}_tab_button`" + class="ide-sidebar-link" + type="button" + @click="clickTab($event, tab)" + > + <icon :size="16" :name="tab.icon" /> + </button> + </li> + </ul> + </nav> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue index 200391282e7..40ed7d9c422 100644 --- a/app/assets/javascripts/ide/components/panes/right.vue +++ b/app/assets/javascripts/ide/components/panes/right.vue @@ -1,27 +1,17 @@ <script> -import { mapActions, mapState, mapGetters } from 'vuex'; -import _ from 'underscore'; +import { mapGetters, mapState } from 'vuex'; import { __ } from '~/locale'; -import tooltip from '../../../vue_shared/directives/tooltip'; -import Icon from '../../../vue_shared/components/icon.vue'; +import CollapsibleSidebar from './collapsible_sidebar.vue'; import { rightSidebarViews } from '../../constants'; +import MergeRequestInfo from '../merge_requests/info.vue'; import PipelinesList from '../pipelines/list.vue'; import JobsDetail from '../jobs/detail.vue'; -import MergeRequestInfo from '../merge_requests/info.vue'; -import ResizablePanel from '../resizable_panel.vue'; import Clientside from '../preview/clientside.vue'; export default { - directives: { - tooltip, - }, + name: 'RightPane', components: { - Icon, - PipelinesList, - JobsDetail, - ResizablePanel, - MergeRequestInfo, - Clientside, + CollapsibleSidebar, }, props: { extensionTabs: { @@ -32,103 +22,40 @@ export default { }, computed: { ...mapState(['currentMergeRequestId', 'clientsidePreviewEnabled']), - ...mapState('rightPane', ['isOpen', 'currentView']), ...mapGetters(['packageJson']), - ...mapGetters('rightPane', ['isActiveView', 'isAliveView']), showLivePreview() { return this.packageJson && this.clientsidePreviewEnabled; }, - defaultTabs() { + rightExtensionTabs() { return [ { - show: this.currentMergeRequestId, + show: Boolean(this.currentMergeRequestId), title: __('Merge Request'), - views: [rightSidebarViews.mergeRequestInfo], + views: [{ component: MergeRequestInfo, ...rightSidebarViews.mergeRequestInfo }], icon: 'text-description', }, { show: true, title: __('Pipelines'), - views: [rightSidebarViews.pipelines, rightSidebarViews.jobsDetail], + views: [ + { component: PipelinesList, ...rightSidebarViews.pipelines }, + { component: JobsDetail, ...rightSidebarViews.jobsDetail }, + ], icon: 'rocket', }, { show: this.showLivePreview, title: __('Live preview'), - views: [rightSidebarViews.clientSidePreview], + views: [{ component: Clientside, ...rightSidebarViews.clientSidePreview }], icon: 'live-preview', }, + ...this.extensionTabs, ]; }, - tabs() { - return this.defaultTabs.concat(this.extensionTabs).filter(tab => tab.show); - }, - tabViews() { - return _.flatten(this.tabs.map(tab => tab.views)); - }, - aliveTabViews() { - return this.tabViews.filter(view => this.isAliveView(view.name)); - }, - }, - methods: { - ...mapActions('rightPane', ['toggleOpen', 'open']), - clickTab(e, tab) { - e.target.blur(); - - if (this.isActiveTab(tab)) { - this.toggleOpen(); - } else { - this.open(tab.views[0]); - } - }, - isActiveTab(tab) { - return tab.views.some(view => this.isActiveView(view.name)); - }, }, }; </script> <template> - <div class="multi-file-commit-panel ide-right-sidebar" data-qa-selector="ide_right_sidebar"> - <resizable-panel - v-show="isOpen" - :collapsible="false" - :initial-width="350" - :min-size="350" - :class="`ide-right-sidebar-${currentView}`" - side="right" - class="multi-file-commit-panel-inner" - > - <div - v-for="tabView in aliveTabViews" - v-show="isActiveView(tabView.name)" - :key="tabView.name" - class="h-100" - > - <component :is="tabView.component || tabView.name" /> - </div> - </resizable-panel> - <nav class="ide-activity-bar"> - <ul class="list-unstyled"> - <li v-for="tab of tabs" :key="tab.title"> - <button - v-tooltip - :title="tab.title" - :aria-label="tab.title" - :class="{ - active: isActiveTab(tab) && isOpen, - }" - data-container="body" - data-placement="left" - :data-qa-selector="`${tab.title.toLowerCase()}_tab_button`" - class="ide-sidebar-link is-right" - type="button" - @click="clickTab($event, tab)" - > - <icon :size="16" :name="tab.icon" /> - </button> - </li> - </ul> - </nav> - </div> + <collapsible-sidebar :extension-tabs="rightExtensionTabs" side="right" :width="350" /> </template> diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index 5201c33b1b4..b3a7597e7bb 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -1,7 +1,6 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; import tooltip from '~/vue_shared/directives/tooltip'; -import Icon from '~/vue_shared/components/icon.vue'; import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; import CommitFilesList from './commit_sidebar/list.vue'; import EmptyState from './commit_sidebar/empty_state.vue'; @@ -11,7 +10,6 @@ import { activityBarViews, stageKeys } from '../constants'; export default { components: { DeprecatedModal, - Icon, CommitFilesList, EmptyState, }, @@ -96,7 +94,6 @@ export default { :empty-state-text="__('There are no unstaged changes')" action="stageAllChanges" action-btn-icon="stage-all" - item-action-component="stage-button" class="is-first" icon-name="unstaged" /> @@ -110,7 +107,6 @@ export default { :empty-state-text="__('There are no staged changes')" action="unstageAllChanges" action-btn-icon="unstage-all" - item-action-component="unstage-button" icon-name="staged" /> </template> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 08b3e8a34d6..7e2ab96d1de 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -32,7 +32,13 @@ export default { ...mapState('rightPane', { rightPaneIsOpen: 'isOpen', }), - ...mapState(['rightPanelCollapsed', 'viewer', 'panelResizing', 'currentActivityView']), + ...mapState([ + 'rightPanelCollapsed', + 'viewer', + 'panelResizing', + 'currentActivityView', + 'renderWhitespaceInCode', + ]), ...mapGetters([ 'currentMergeRequest', 'getStagedFile', @@ -76,6 +82,11 @@ export default { showEditor() { return !this.shouldHideEditor && this.isEditorViewMode; }, + editorOptions() { + return { + renderWhitespace: this.renderWhitespaceInCode ? 'all' : 'none', + }; + }, }, watch: { file(newVal, oldVal) { @@ -131,7 +142,7 @@ export default { }, mounted() { if (!this.editor) { - this.editor = Editor.create(); + this.editor = Editor.create(this.editorOptions); } this.initEditor(); }, diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue index 4dbc4383894..1b7f149097b 100644 --- a/app/assets/javascripts/ide/components/repo_tabs.vue +++ b/app/assets/javascripts/ide/components/repo_tabs.vue @@ -1,13 +1,11 @@ <script> import { mapActions } from 'vuex'; import RepoTab from './repo_tab.vue'; -import EditorMode from './editor_mode_dropdown.vue'; import router from '../ide_router'; export default { components: { RepoTab, - EditorMode, }, props: { activeFile: { diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index cdfebd19fa4..4c4166e11f5 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -50,6 +50,7 @@ export function initIde(el, options = {}) { }); this.setInitialData({ clientsidePreviewEnabled: parseBoolean(el.dataset.clientsidePreviewEnabled), + renderWhitespaceInCode: parseBoolean(el.dataset.renderWhitespaceInCode), }); }, methods: { diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js index 02038fcb534..d1056ea6b98 100644 --- a/app/assets/javascripts/ide/lib/editor.js +++ b/app/assets/javascripts/ide/lib/editor.js @@ -23,20 +23,24 @@ export const clearDomElement = el => { }; export default class Editor { - static create() { + static create(options = {}) { if (!this.editorInstance) { - this.editorInstance = new Editor(); + this.editorInstance = new Editor(options); } return this.editorInstance; } - constructor() { + constructor(options = {}) { this.currentModel = null; this.instance = null; this.dirtyDiffController = null; this.disposable = new Disposable(); this.modelManager = new ModelManager(); this.decorationsController = new DecorationsController(this); + this.options = { + ...defaultEditorOptions, + ...options, + }; setupMonacoTheme(); @@ -51,7 +55,7 @@ export default class Editor { this.disposable.add( (this.instance = monacoEditor.create(domElement, { - ...defaultEditorOptions, + ...this.options, })), (this.dirtyDiffController = new DirtyDiffController( this.modelManager, @@ -71,7 +75,7 @@ export default class Editor { this.disposable.add( (this.instance = monacoEditor.createDiffEditor(domElement, { - ...defaultEditorOptions, + ...this.options, quickSuggestions: false, occurrencesHighlight: false, renderSideBySide: Editor.renderSideBySide(domElement), diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index dd69e2d6f1f..34e7cc304dd 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -16,21 +16,7 @@ export const redirectToUrl = (self, url) => visitUrl(url); export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); export const discardAllChanges = ({ state, commit, dispatch }) => { - state.changedFiles.forEach(file => { - if (file.tempFile || file.prevPath) dispatch('closeFile', file); - - if (file.tempFile) { - dispatch('deleteEntry', file.path); - } else if (file.prevPath) { - dispatch('renameEntry', { - path: file.path, - name: file.prevName, - parentPath: file.prevParentPath, - }); - } else { - commit(types.DISCARD_FILE_CHANGES, file.path); - } - }); + state.changedFiles.forEach(file => dispatch('restoreOriginalFile', file.path)); commit(types.REMOVE_ALL_CHANGES_FILES); }; @@ -47,79 +33,66 @@ export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => { } }; -export const toggleRightPanelCollapsed = ({ dispatch, state }, e = undefined) => { - if (e) { - $(e.currentTarget) - .tooltip('hide') - .blur(); - } - - dispatch('setPanelCollapsedStatus', { - side: 'right', - collapsed: !state.rightPanelCollapsed, - }); -}; - export const setResizingStatus = ({ commit }, resizing) => { commit(types.SET_RESIZING_STATUS, resizing); }; export const createTempEntry = ( - { state, commit, dispatch }, + { state, commit, dispatch, getters }, { name, type, content = '', base64 = false, binary = false, rawPath = '' }, -) => - new Promise(resolve => { - const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name; - - if (state.entries[name] && !state.entries[name].deleted) { - flash( - `The name "${name.split('/').pop()}" is already taken in this directory.`, - 'alert', - document, - null, - false, - true, - ); - - resolve(); - - return null; - } +) => { + const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name; + + if (state.entries[name] && !state.entries[name].deleted) { + flash( + sprintf(__('The name "%{name}" is already taken in this directory.'), { + name: name.split('/').pop(), + }), + 'alert', + document, + null, + false, + true, + ); - const data = decorateFiles({ - data: [fullName], - projectId: state.currentProjectId, - branchId: state.currentBranchId, - type, - tempFile: true, - content, - base64, - binary, - rawPath, - }); - const { file, parentPath } = data; + return; + } - commit(types.CREATE_TMP_ENTRY, { - data, - projectId: state.currentProjectId, - branchId: state.currentBranchId, - }); + const data = decorateFiles({ + data: [fullName], + projectId: state.currentProjectId, + branchId: state.currentBranchId, + type, + tempFile: true, + content, + base64, + binary, + rawPath, + }); + const { file, parentPath } = data; - if (type === 'blob') { - commit(types.TOGGLE_FILE_OPEN, file.path); - commit(types.ADD_FILE_TO_CHANGED, file.path); - dispatch('setFileActive', file.path); - dispatch('triggerFilesChange'); - } + commit(types.CREATE_TMP_ENTRY, { + data, + projectId: state.currentProjectId, + branchId: state.currentBranchId, + }); - if (parentPath && !state.entries[parentPath].opened) { - commit(types.TOGGLE_TREE_OPEN, parentPath); - } + if (type === 'blob') { + commit(types.TOGGLE_FILE_OPEN, file.path); - resolve(file); + if (gon.features?.stageAllByDefault) + commit(types.STAGE_CHANGE, { path: file.path, diffInfo: getters.getDiffInfo(file.path) }); + else commit(types.ADD_FILE_TO_CHANGED, file.path); - return null; - }); + dispatch('setFileActive', file.path); + dispatch('triggerFilesChange'); + dispatch('burstUnusedSeal'); + } + + if (parentPath && !state.entries[parentPath].opened) { + commit(types.TOGGLE_TREE_OPEN, parentPath); + } +}; export const scrollToTab = () => { Vue.nextTick(() => { @@ -133,28 +106,40 @@ export const scrollToTab = () => { }); }; -export const stageAllChanges = ({ state, commit, dispatch }) => { +export const stageAllChanges = ({ state, commit, dispatch, getters }) => { const openFile = state.openFiles[0]; commit(types.SET_LAST_COMMIT_MSG, ''); - state.changedFiles.forEach(file => commit(types.STAGE_CHANGE, file.path)); + state.changedFiles.forEach(file => + commit(types.STAGE_CHANGE, { path: file.path, diffInfo: getters.getDiffInfo(file.path) }), + ); - dispatch('openPendingTab', { - file: state.stagedFiles.find(f => f.path === openFile.path), - keyPrefix: stageKeys.staged, - }); + const file = getters.getStagedFile(openFile.path); + + if (file) { + dispatch('openPendingTab', { + file, + keyPrefix: stageKeys.staged, + }); + } }; -export const unstageAllChanges = ({ state, commit, dispatch }) => { +export const unstageAllChanges = ({ state, commit, dispatch, getters }) => { const openFile = state.openFiles[0]; - state.stagedFiles.forEach(file => commit(types.UNSTAGE_CHANGE, file.path)); + state.stagedFiles.forEach(file => + commit(types.UNSTAGE_CHANGE, { path: file.path, diffInfo: getters.getDiffInfo(file.path) }), + ); - dispatch('openPendingTab', { - file: state.changedFiles.find(f => f.path === openFile.path), - keyPrefix: stageKeys.unstaged, - }); + const file = getters.getChangedFile(openFile.path); + + if (file) { + dispatch('openPendingTab', { + file, + keyPrefix: stageKeys.unstaged, + }); + } }; export const updateViewer = ({ commit }, viewer) => { @@ -212,8 +197,9 @@ export const deleteEntry = ({ commit, dispatch, state }, path) => { const entry = state.entries[path]; const { prevPath, prevName, prevParentPath } = entry; const isTree = entry.type === 'tree'; + const prevEntry = prevPath && state.entries[prevPath]; - if (prevPath) { + if (prevPath && (!prevEntry || prevEntry.deleted)) { dispatch('renameEntry', { path, name: prevName, @@ -222,7 +208,9 @@ export const deleteEntry = ({ commit, dispatch, state }, path) => { dispatch('deleteEntry', prevPath); return; } - if (state.unusedSeal) dispatch('burstUnusedSeal'); + + dispatch('burstUnusedSeal'); + if (entry.opened) dispatch('closeFile', entry); if (isTree) { @@ -241,9 +229,14 @@ export const deleteEntry = ({ commit, dispatch, state }, path) => { export const resetOpenFiles = ({ commit }) => commit(types.RESET_OPEN_FILES); -export const renameEntry = ({ dispatch, commit, state }, { path, name, parentPath }) => { +export const renameEntry = ({ dispatch, commit, state, getters }, { path, name, parentPath }) => { const entry = state.entries[path]; const newPath = parentPath ? `${parentPath}/${name}` : name; + const existingParent = parentPath && state.entries[parentPath]; + + if (parentPath && (!existingParent || existingParent.deleted)) { + dispatch('createTempEntry', { name: parentPath, type: 'tree' }); + } commit(types.RENAME_ENTRY, { path, name, parentPath }); @@ -266,7 +259,11 @@ export const renameEntry = ({ dispatch, commit, state }, { path, name, parentPat if (isReset) { commit(types.REMOVE_FILE_FROM_STAGED_AND_CHANGED, newEntry); } else if (!isInChanges) { - commit(types.ADD_FILE_TO_CHANGED, newPath); + if (gon.features?.stageAllByDefault) + commit(types.STAGE_CHANGE, { path: newPath, diffInfo: getters.getDiffInfo(newPath) }); + else commit(types.ADD_FILE_TO_CHANGED, newPath); + + dispatch('burstUnusedSeal'); } if (!newEntry.tempFile) { diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 8864224c19e..70a966afa66 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -61,8 +61,10 @@ export const getFileData = ( { path, makeFileActive = true, openFile = makeFileActive }, ) => { const file = state.entries[path]; + const fileDeletedAndReadded = getters.isFileDeletedAndReadded(path); - if (file.raw || (file.tempFile && !file.prevPath)) return Promise.resolve(); + if (file.raw || (file.tempFile && !file.prevPath && !fileDeletedAndReadded)) + return Promise.resolve(); commit(types.TOGGLE_LOADING, { entry: file }); @@ -102,11 +104,16 @@ export const setFileMrChange = ({ commit }, { file, mrChange }) => { export const getRawFileData = ({ state, commit, dispatch, getters }, { path }) => { const file = state.entries[path]; + const stagedFile = state.stagedFiles.find(f => f.path === path); + return new Promise((resolve, reject) => { + const fileDeletedAndReadded = getters.isFileDeletedAndReadded(path); service - .getRawFileData(file) + .getRawFileData(fileDeletedAndReadded ? stagedFile : file) .then(raw => { - if (!(file.tempFile && !file.prevPath)) commit(types.SET_FILE_RAW_DATA, { file, raw }); + if (!(file.tempFile && !file.prevPath && !fileDeletedAndReadded)) + commit(types.SET_FILE_RAW_DATA, { file, raw, fileDeletedAndReadded }); + if (file.mrChange && file.mrChange.new_file === false) { const baseSha = (getters.currentMergeRequest && getters.currentMergeRequest.baseCommitSha) || ''; @@ -140,7 +147,7 @@ export const getRawFileData = ({ state, commit, dispatch, getters }, { path }) = }); }; -export const changeFileContent = ({ commit, dispatch, state }, { path, content }) => { +export const changeFileContent = ({ commit, dispatch, state, getters }, { path, content }) => { const file = state.entries[path]; commit(types.UPDATE_FILE_CONTENT, { path, @@ -150,8 +157,10 @@ export const changeFileContent = ({ commit, dispatch, state }, { path, content } const indexOfChangedFile = state.changedFiles.findIndex(f => f.path === path); if (file.changed && indexOfChangedFile === -1) { - commit(types.ADD_FILE_TO_CHANGED, path); - } else if (!file.changed && indexOfChangedFile !== -1) { + if (gon.features?.stageAllByDefault) + commit(types.STAGE_CHANGE, { path, diffInfo: getters.getDiffInfo(path) }); + else commit(types.ADD_FILE_TO_CHANGED, path); + } else if (!file.changed && !file.tempFile && indexOfChangedFile !== -1) { commit(types.REMOVE_FILE_FROM_CHANGED, path); } @@ -184,23 +193,40 @@ export const setFileViewMode = ({ commit }, { file, viewMode }) => { commit(types.SET_FILE_VIEWMODE, { file, viewMode }); }; -export const discardFileChanges = ({ dispatch, state, commit, getters }, path) => { +export const restoreOriginalFile = ({ dispatch, state, commit }, path) => { const file = state.entries[path]; + const isDestructiveDiscard = file.tempFile || file.prevPath; if (file.deleted && file.parentPath) { dispatch('restoreTree', file.parentPath); } - commit(types.DISCARD_FILE_CHANGES, path); - commit(types.REMOVE_FILE_FROM_CHANGED, path); + if (isDestructiveDiscard) { + dispatch('closeFile', file); + } + + if (file.tempFile) { + dispatch('deleteEntry', file.path); + } else { + commit(types.DISCARD_FILE_CHANGES, file.path); + } if (file.prevPath) { - dispatch('discardFileChanges', file.prevPath); + dispatch('renameEntry', { + path: file.path, + name: file.prevName, + parentPath: file.prevParentPath, + }); } +}; - if (file.tempFile && file.opened) { - commit(types.TOGGLE_FILE_OPEN, path); - } else if (getters.activeFile && file.path === getters.activeFile.path) { +export const discardFileChanges = ({ dispatch, state, commit, getters }, path) => { + const file = state.entries[path]; + const isDestructiveDiscard = file.tempFile || file.prevPath; + + dispatch('restoreOriginalFile', path); + + if (!isDestructiveDiscard && file.path === getters.activeFile?.path) { dispatch('updateDelayViewerUpdated', true) .then(() => { router.push(`/project${file.url}`); @@ -210,24 +236,26 @@ export const discardFileChanges = ({ dispatch, state, commit, getters }, path) = }); } + commit(types.REMOVE_FILE_FROM_CHANGED, path); + eventHub.$emit(`editor.update.model.new.content.${file.key}`, file.content); eventHub.$emit(`editor.update.model.dispose.unstaged-${file.key}`, file.content); }; -export const stageChange = ({ commit, state, dispatch }, path) => { - const stagedFile = state.stagedFiles.find(f => f.path === path); - const openFile = state.openFiles.find(f => f.path === path); +export const stageChange = ({ commit, dispatch, getters }, path) => { + const stagedFile = getters.getStagedFile(path); + const openFile = getters.getOpenFile(path); - commit(types.STAGE_CHANGE, path); + commit(types.STAGE_CHANGE, { path, diffInfo: getters.getDiffInfo(path) }); commit(types.SET_LAST_COMMIT_MSG, ''); if (stagedFile) { eventHub.$emit(`editor.update.model.new.content.staged-${stagedFile.key}`, stagedFile.content); } - if (openFile && openFile.active) { - const file = state.stagedFiles.find(f => f.path === path); + const file = getters.getStagedFile(path); + if (openFile && openFile.active && file) { dispatch('openPendingTab', { file, keyPrefix: stageKeys.staged, @@ -235,14 +263,14 @@ export const stageChange = ({ commit, state, dispatch }, path) => { } }; -export const unstageChange = ({ commit, dispatch, state }, path) => { - const openFile = state.openFiles.find(f => f.path === path); +export const unstageChange = ({ commit, dispatch, getters }, path) => { + const openFile = getters.getOpenFile(path); - commit(types.UNSTAGE_CHANGE, path); + commit(types.UNSTAGE_CHANGE, { path, diffInfo: getters.getDiffInfo(path) }); - if (openFile && openFile.active) { - const file = state.changedFiles.find(f => f.path === path); + const file = getters.getChangedFile(path); + if (openFile && openFile.active && file) { dispatch('openPendingTab', { file, keyPrefix: stageKeys.unstaged, diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js index 6790c0fbdaa..806ec38430c 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -141,7 +141,7 @@ export const getMergeRequestVersions = ( }); export const openMergeRequest = ( - { dispatch, state }, + { dispatch, state, getters }, { projectId, targetProjectId, mergeRequestId } = {}, ) => dispatch('getMergeRequestData', { @@ -152,17 +152,18 @@ export const openMergeRequest = ( .then(mr => { dispatch('setCurrentBranchId', mr.source_branch); - // getFiles needs to be called after getting the branch data - // since files are fetched using the last commit sha of the branch return dispatch('getBranchData', { projectId, branchId: mr.source_branch, - }).then(() => - dispatch('getFiles', { + }).then(() => { + const branch = getters.findBranch(projectId, mr.source_branch); + + return dispatch('getFiles', { projectId, branchId: mr.source_branch, - }), - ); + ref: branch.commit.id, + }); + }); }) .then(() => dispatch('getMergeRequestVersions', { diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index 20887e7d0ac..e206f9bee9e 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -83,8 +83,11 @@ export const showBranchNotFoundError = ({ dispatch }, branchId) => { }); }; -export const showEmptyState = ({ commit, state }, { projectId, branchId }) => { +export const showEmptyState = ({ commit, state, dispatch }, { projectId, branchId }) => { const treePath = `${projectId}/${branchId}`; + + dispatch('setCurrentBranchId', branchId); + commit(types.CREATE_TREE, { treePath }); commit(types.TOGGLE_LOADING, { entry: state.trees[treePath], @@ -111,7 +114,7 @@ export const loadFile = ({ dispatch, state }, { basePath }) => { } }; -export const loadBranch = ({ dispatch }, { projectId, branchId }) => +export const loadBranch = ({ dispatch, getters }, { projectId, branchId }) => dispatch('getBranchData', { projectId, branchId, @@ -121,9 +124,13 @@ export const loadBranch = ({ dispatch }, { projectId, branchId }) => projectId, branchId, }); + + const branch = getters.findBranch(projectId, branchId); + return dispatch('getFiles', { projectId, branchId, + ref: branch.commit.id, }); }) .catch(() => { diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js index 72cd099c5a5..ba85194b910 100644 --- a/app/assets/javascripts/ide/stores/actions/tree.js +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -46,19 +46,20 @@ export const setDirectoryData = ({ state, commit }, { projectId, branchId, treeL }); }; -export const getFiles = ({ state, commit, dispatch, getters }, { projectId, branchId } = {}) => +export const getFiles = ({ state, commit, dispatch }, payload = {}) => new Promise((resolve, reject) => { + const { projectId, branchId, ref = branchId } = payload; + if ( !state.trees[`${projectId}/${branchId}`] || (state.trees[`${projectId}/${branchId}`].tree && state.trees[`${projectId}/${branchId}`].tree.length === 0) ) { const selectedProject = state.projects[projectId]; - const selectedBranch = getters.findBranch(projectId, branchId); commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` }); service - .getFiles(selectedProject.web_url, selectedBranch.commit.id) + .getFiles(selectedProject.web_url, ref) .then(({ data }) => { const { entries, treeList } = decorateFiles({ data, @@ -77,8 +78,8 @@ export const getFiles = ({ state, commit, dispatch, getters }, { projectId, bran .catch(e => { dispatch('setErrorMessage', { text: __('An error occurred whilst loading all the files.'), - action: payload => - dispatch('getFiles', payload).then(() => dispatch('setErrorMessage', null)), + action: actionPayload => + dispatch('getFiles', actionPayload).then(() => dispatch('setErrorMessage', null)), actionText: __('Please try again'), actionPayload: { projectId, branchId }, }); diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index bb8374b4e78..2fc574cd343 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -64,6 +64,7 @@ export const allBlobs = state => export const getChangedFile = state => path => state.changedFiles.find(f => f.path === path); export const getStagedFile = state => path => state.stagedFiles.find(f => f.path === path); +export const getOpenFile = state => path => state.openFiles.find(f => f.path === path); export const lastOpenedFile = state => [...state.changedFiles, ...state.stagedFiles].sort((a, b) => b.lastOpenedAt - a.lastOpenedAt)[0]; diff --git a/app/assets/javascripts/ide/stores/modules/pane/actions.js b/app/assets/javascripts/ide/stores/modules/pane/actions.js index 7f5d167a14f..a8fcdf539ec 100644 --- a/app/assets/javascripts/ide/stores/modules/pane/actions.js +++ b/app/assets/javascripts/ide/stores/modules/pane/actions.js @@ -1,17 +1,17 @@ import * as types from './mutation_types'; -export const toggleOpen = ({ dispatch, state }, view) => { +export const toggleOpen = ({ dispatch, state }) => { if (state.isOpen) { dispatch('close'); } else { - dispatch('open', view); + dispatch('open'); } }; -export const open = ({ commit }, view) => { +export const open = ({ state, commit }, view) => { commit(types.SET_OPEN, true); - if (view) { + if (view && view.name !== state.currentView) { const { name, keepAlive } = view; commit(types.SET_CURRENT_VIEW, name); diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index f0b4718d025..4dde53a9fdf 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -11,7 +11,6 @@ export const SET_LINKS = 'SET_LINKS'; // Project Mutation Types export const SET_PROJECT = 'SET_PROJECT'; export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT'; -export const TOGGLE_PROJECT_OPEN = 'TOGGLE_PROJECT_OPEN'; export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE'; // Merge Request Mutation Types diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index 8caeb2d73b2..313fa1fe029 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -54,27 +54,29 @@ export default { } }); }, - [types.SET_FILE_RAW_DATA](state, { file, raw }) { + [types.SET_FILE_RAW_DATA](state, { file, raw, fileDeletedAndReadded = false }) { const openPendingFile = state.openFiles.find( - f => f.path === file.path && f.pending && !(f.tempFile && !f.prevPath), + f => + f.path === file.path && f.pending && !(f.tempFile && !f.prevPath && !fileDeletedAndReadded), ); + const stagedFile = state.stagedFiles.find(f => f.path === file.path); - if (file.tempFile && file.content === '') { - Object.assign(state.entries[file.path], { - content: raw, - }); + if (file.tempFile && file.content === '' && !fileDeletedAndReadded) { + Object.assign(state.entries[file.path], { content: raw }); + } else if (fileDeletedAndReadded) { + Object.assign(stagedFile, { raw }); } else { - Object.assign(state.entries[file.path], { - raw, - }); + Object.assign(state.entries[file.path], { raw }); } if (!openPendingFile) return; if (!openPendingFile.tempFile) { openPendingFile.raw = raw; - } else if (openPendingFile.tempFile) { + } else if (openPendingFile.tempFile && !fileDeletedAndReadded) { openPendingFile.content = raw; + } else if (fileDeletedAndReadded) { + Object.assign(stagedFile, { raw }); } }, [types.SET_FILE_BASE_RAW_DATA](state, { file, baseRaw }) { @@ -132,7 +134,7 @@ export default { [types.DISCARD_FILE_CHANGES](state, path) { const stagedFile = state.stagedFiles.find(f => f.path === path); const entry = state.entries[path]; - const { deleted, prevPath } = entry; + const { deleted } = entry; Object.assign(state.entries[path], { content: stagedFile ? stagedFile.content : state.entries[path].raw, @@ -146,12 +148,6 @@ export default { : state.trees[`${state.currentProjectId}/${state.currentBranchId}`]; parent.tree = sortTree(parent.tree.concat(entry)); - } else if (prevPath) { - const parent = entry.parentPath - ? state.entries[entry.parentPath] - : state.trees[`${state.currentProjectId}/${state.currentBranchId}`]; - - parent.tree = parent.tree.filter(f => f.path !== path); } }, [types.ADD_FILE_TO_CHANGED](state, path) { @@ -164,31 +160,32 @@ export default { changedFiles: state.changedFiles.filter(f => f.path !== path), }); }, - [types.STAGE_CHANGE](state, path) { + [types.STAGE_CHANGE](state, { path, diffInfo }) { const stagedFile = state.stagedFiles.find(f => f.path === path); Object.assign(state, { changedFiles: state.changedFiles.filter(f => f.path !== path), entries: Object.assign(state.entries, { [path]: Object.assign(state.entries[path], { - staged: true, + staged: diffInfo.exists, + changed: diffInfo.changed, + tempFile: diffInfo.tempFile, + deleted: diffInfo.deleted, }), }), }); if (stagedFile) { - Object.assign(stagedFile, { - ...state.entries[path], - }); + Object.assign(stagedFile, { ...state.entries[path] }); } else { - Object.assign(state, { - stagedFiles: state.stagedFiles.concat({ - ...state.entries[path], - }), - }); + state.stagedFiles = [...state.stagedFiles, { ...state.entries[path] }]; + } + + if (!diffInfo.exists) { + state.stagedFiles = state.stagedFiles.filter(f => f.path !== path); } }, - [types.UNSTAGE_CHANGE](state, path) { + [types.UNSTAGE_CHANGE](state, { path, diffInfo }) { const changedFile = state.changedFiles.find(f => f.path === path); const stagedFile = state.stagedFiles.find(f => f.path === path); @@ -201,9 +198,11 @@ export default { changed: true, }); - Object.assign(state, { - changedFiles: state.changedFiles.concat(state.entries[path]), - }); + state.changedFiles = state.changedFiles.concat(state.entries[path]); + } + + if (!diffInfo.exists) { + state.changedFiles = state.changedFiles.filter(f => f.path !== path); } Object.assign(state, { @@ -211,6 +210,9 @@ export default { entries: Object.assign(state.entries, { [path]: Object.assign(state.entries[path], { staged: false, + changed: diffInfo.changed, + tempFile: diffInfo.tempFile, + deleted: diffInfo.deleted, }), }), }); diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index d400b9831a9..6488389977c 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -31,4 +31,5 @@ export default () => ({ entry: {}, }, clientsidePreviewEnabled: false, + renderWhitespaceInCode: false, }); diff --git a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue index 3c6c9c71b8c..6e227ab3d82 100644 --- a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue +++ b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue @@ -2,7 +2,6 @@ import { mapState, mapGetters, mapActions } from 'vuex'; import Select2Select from '~/vue_shared/components/select2_select.vue'; import { __ } from '~/locale'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; import eventHub from '../event_hub'; import { STATUSES } from '../constants'; import ImportStatus from './import_status.vue'; @@ -11,7 +10,6 @@ export default { name: 'ProviderRepoTableRow', components: { Select2Select, - LoadingButton, ImportStatus, }, props: { diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js index 48e7ed1318d..566efa0d7d6 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable_context.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import Cookies from 'js-cookie'; -import bp from './breakpoints'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import UsersSelect from './users_select'; export default class IssuableContext { @@ -48,7 +48,9 @@ export default class IssuableContext { window.addEventListener('beforeunload', () => { // collapsed_gutter cookie hides the sidebar const bpBreakpoint = bp.getBreakpointSize(); - if (bpBreakpoint === 'xs' || bpBreakpoint === 'sm') { + const supportedSizes = ['xs', 'sm', 'md']; + + if (supportedSizes.includes(bpBreakpoint)) { Cookies.set('collapsed_gutter', true); } }); diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index 1d0807dc15d..cf780556c8d 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -8,19 +8,23 @@ import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; import { parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility'; import { queryToObject, objectToQuery } from './lib/utils/url_utility'; +const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; +const MR_TARGET_BRANCH = 'merge_request[target_branch]'; + function organizeQuery(obj, isFallbackKey = false) { - const sourceBranch = 'merge_request[source_branch]'; - const targetBranch = 'merge_request[target_branch]'; + if (!obj[MR_SOURCE_BRANCH] && !obj[MR_TARGET_BRANCH]) { + return obj; + } if (isFallbackKey) { return { - [sourceBranch]: obj[sourceBranch], + [MR_SOURCE_BRANCH]: obj[MR_SOURCE_BRANCH], }; } return { - [sourceBranch]: obj[sourceBranch], - [targetBranch]: obj[targetBranch], + [MR_SOURCE_BRANCH]: obj[MR_SOURCE_BRANCH], + [MR_TARGET_BRANCH]: obj[MR_TARGET_BRANCH], }; } @@ -87,7 +91,8 @@ export default class IssuableForm { } initAutosave() { - const searchTerm = format(document.location.search); + const { search } = document.location; + const searchTerm = format(search); const fallbackKey = getFallbackKey(); this.autosave = new Autosave( diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index 859f839741f..809b3d5f57e 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -2,9 +2,9 @@ import _ from 'underscore'; import { mapGetters, mapState, mapActions } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; import { polyfillSticky } from '~/lib/utils/sticky'; -import bp from '~/breakpoints'; import CiHeader from '~/vue_shared/components/header_ci_component.vue'; import Callout from '~/vue_shared/components/callout.vue'; import Icon from '~/vue_shared/components/icon.vue'; @@ -200,7 +200,8 @@ export default { this.updateScroll(); }, updateSidebar() { - if (bp.getBreakpointSize() === 'xs') { + const breakpoint = bp.getBreakpointSize(); + if (breakpoint === 'xs' || breakpoint === 'sm') { this.hideSidebar(); } else if (!this.isSidebarOpen) { this.showSidebar(); diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue index eb0de53f36a..f0bdbde0602 100644 --- a/app/assets/javascripts/jobs/components/log/log.vue +++ b/app/assets/javascripts/jobs/components/log/log.vue @@ -49,7 +49,7 @@ export default { }; </script> <template> - <code class="job-log d-block"> + <code class="job-log d-block" data-qa-selector="job_log_content"> <template v-for="(section, index) in trace"> <collpasible-log-section v-if="section.isHeader" diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue index 6e92b599b0a..09f9647a680 100644 --- a/app/assets/javascripts/jobs/components/stages_dropdown.vue +++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue @@ -2,12 +2,10 @@ import _ from 'underscore'; import { GlLink } from '@gitlab/ui'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import Icon from '~/vue_shared/components/icon.vue'; export default { components: { CiIcon, - Icon, GlLink, }, props: { diff --git a/app/assets/javascripts/jobs/svg/scroll_down.svg b/app/assets/javascripts/jobs/svg/scroll_down.svg index 1d22870ec09..fb934f68704 100644 --- a/app/assets/javascripts/jobs/svg/scroll_down.svg +++ b/app/assets/javascripts/jobs/svg/scroll_down.svg @@ -1,5 +1,4 @@ -<svg width="12" height="16" viewBox="0 0 12 16" xmlns="http://www.w3.org/2000/svg"> - <path class="first-triangle" d="M1.048 14.155a.508.508 0 0 0-.32.105c-.091.07-.136.154-.136.25v.71c0 .095.045.178.135.249.09.07.197.105.321.105h10.043c.124 0 .23-.035.321-.105.09-.07.136-.154.136-.25v-.71c0-.095-.045-.178-.136-.249a.508.508 0 0 0-.32-.105"/> - <path class="second-triangle" d="M.687 8.027c-.09-.087-.122-.16-.093-.22.028-.06.104-.09.228-.09h10.5c.123 0 .2.03.228.09.029.06-.002.133-.093.22L6.393 12.91a.458.458 0 0 1-.136.089h-.37a.626.626 0 0 1-.136-.09"/> - <path class="third-triangle" d="M.687 1.027C.597.94.565.867.594.807c.028-.06.104-.09.228-.09h10.5c.123 0 .2.03.228.09.029.06-.002.133-.093.22L6.393 5.91A.458.458 0 0 1 6.257 6h-.37a.626.626 0 0 1-.136-.09"/> +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path class="scroll-arrow" d="M8 10.4142L4.29289 6.70711C3.90237 6.31658 3.90237 5.68342 4.29289 5.2929C4.68342 4.90237 5.31658 4.90237 5.70711 5.2929L7 6.58579L7 1C7 0.447715 7.44772 0 8 0C8.55229 0 9 0.447715 9 1L9 6.58579L10.2929 5.2929C10.6834 4.90237 11.3166 4.90237 11.7071 5.2929C12.0976 5.68342 12.0976 6.31658 11.7071 6.70711L8 10.4142Z"/> +<path class="scroll-dot" d="M8 16C9.10457 16 10 15.1046 10 14C10 12.8954 9.10457 12 8 12C6.89543 12 6 12.8954 6 14C6 15.1046 6.89543 16 8 16Z"/> </svg> diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 6abf723be9a..f57febbda37 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -45,6 +45,7 @@ export default class LabelsSelect { const $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip'); const $value = $block.find('.value'); const $dropdownMenu = $dropdown.parent().find('.dropdown-menu'); + // eslint-disable-next-line no-jquery/no-fade const $loading = $block.find('.block-loading').fadeOut(); const fieldName = $dropdown.data('fieldName'); let initialSelected = $selectbox @@ -84,6 +85,7 @@ export default class LabelsSelect { if (!selected.length) { data[abilityName].label_ids = ['']; } + // eslint-disable-next-line no-jquery/no-fade $loading.removeClass('hidden').fadeIn(); $dropdown.trigger('loading.gl.dropdown'); axios @@ -91,6 +93,7 @@ export default class LabelsSelect { .then(({ data }) => { let labelTooltipTitle; let template; + // eslint-disable-next-line no-jquery/no-fade $loading.fadeOut(); $dropdown.trigger('loaded.gl.dropdown'); $selectbox.hide(); @@ -361,6 +364,7 @@ export default class LabelsSelect { const label = clickEvent.selectedObj; const fadeOutLoader = () => { + // eslint-disable-next-line no-jquery/no-fade $loading.fadeOut(); }; @@ -422,6 +426,7 @@ export default class LabelsSelect { boardsStore.detail.issue.labels = labels; } + // eslint-disable-next-line no-jquery/no-fade $loading.fadeIn(); const oldLabels = boardsStore.detail.issue.labels; diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index e4001e94478..a2591180039 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -2,12 +2,12 @@ * @module common-utils */ +import { GlBreakpointInstance as breakpointInstance } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; import axios from './axios_utils'; import { getLocationHash } from './url_utility'; import { convertToCamelCase, convertToSnakeCase } from './text_utility'; import { isObject } from './type_utility'; -import breakpointInstance from '../../breakpoints'; export const getPagePath = (index = 0) => { const page = $('body').attr('data-page') || ''; @@ -135,7 +135,9 @@ export const handleLocationHash = () => { adjustment -= topPadding; } - window.scrollBy(0, adjustment); + setTimeout(() => { + window.scrollBy(0, adjustment); + }); }; // Check if element scrolled into viewport from above or below @@ -247,6 +249,7 @@ export const scrollToElement = element => { } const { top } = $el.offset(); + // eslint-disable-next-line no-jquery/no-animate return $('body, html').animate( { scrollTop: top - contentTop(), @@ -481,6 +484,16 @@ export const historyPushState = newUrl => { }; /** + * Based on the current location and the string parameters provided + * overwrites the current entry in the history without reloading the page. + * + * @param {String} param + */ +export const historyReplaceState = newUrl => { + window.history.replaceState({}, document.title, newUrl); +}; + +/** * Returns true for a String value of "true" and false otherwise. * This is the opposite of Boolean(...).toString(). * `parseBoolean` is idempotent. diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 996692bacb3..fd9a13be18b 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -392,15 +392,21 @@ export const getTimeframeWindowFrom = (initialStartDate, length) => { * @param {Date} date * @param {Array} quarter */ -export const dayInQuarter = (date, quarter) => - quarter.reduce((acc, month) => { - if (date.getMonth() > month.getMonth()) { +export const dayInQuarter = (date, quarter) => { + const dateValues = { + date: date.getDate(), + month: date.getMonth(), + }; + + return quarter.reduce((acc, month) => { + if (dateValues.month > month.getMonth()) { return acc + totalDaysInMonth(month); - } else if (date.getMonth() === month.getMonth()) { - return acc + date.getDate(); + } else if (dateValues.month === month.getMonth()) { + return acc + dateValues.date; } return acc + 0; }, 0); +}; window.gl = window.gl || {}; window.gl.utils = { @@ -464,7 +470,7 @@ export const pikadayToString = date => { */ export const parseSeconds = ( seconds, - { daysPerWeek = 5, hoursPerDay = 8, limitToHours = false } = {}, + { daysPerWeek = 5, hoursPerDay = 8, limitToHours = false, limitToDays = false } = {}, ) => { const DAYS_PER_WEEK = daysPerWeek; const HOURS_PER_DAY = hoursPerDay; @@ -480,8 +486,11 @@ export const parseSeconds = ( minutes: 1, }; - if (limitToHours) { + if (limitToDays || limitToHours) { timePeriodConstraints.weeks = 0; + } + + if (limitToHours) { timePeriodConstraints.days = 0; } @@ -546,6 +555,16 @@ export const calculateRemainingMilliseconds = endDate => { export const getDateInPast = (date, daysInPast) => new Date(newDate(date).setDate(date.getDate() - daysInPast)); +/** + * Adds a given number of days to a given date and returns the new date. + * + * @param {Date} date the date that we will add days to + * @param {Number} daysInFuture number of days that are added to a given date + * @returns {Date} Date in future as Date object + */ +export const getDateInFuture = (date, daysInFuture) => + new Date(newDate(date).setDate(date.getDate() + daysInFuture)); + /* * Appending T00:00:00 makes JS assume local time and prevents it from shifting the date * to match the user's time zone. We want to display the date in server time for now, to @@ -606,3 +625,44 @@ export const secondsToDays = seconds => Math.round(seconds / 86400); * @return {Date} the date following the date provided */ export const dayAfter = date => new Date(newDate(date).setDate(date.getDate() + 1)); + +/** + * Mimics the behaviour of the rails distance_of_time_in_words function + * https://api.rubyonrails.org/v6.0.1/classes/ActionView/Helpers/DateHelper.html#method-i-distance_of_time_in_words + * 0 < -> 29 secs => less than a minute + * 30 secs < -> 1 min, 29 secs => 1 minute + * 1 min, 30 secs < -> 44 mins, 29 secs => [2..44] minutes + * 44 mins, 30 secs < -> 89 mins, 29 secs => about 1 hour + * 89 mins, 30 secs < -> 23 hrs, 59 mins, 29 secs => about[2..24]hours + * 23 hrs, 59 mins, 30 secs < -> 41 hrs, 59 mins, 29 secs => 1 day + * 41 hrs, 59 mins, 30 secs => x days + * + * @param {Number} seconds + * @return {String} approximated time + */ +export const approximateDuration = (seconds = 0) => { + if (!_.isNumber(seconds) || seconds < 0) { + return ''; + } + + const ONE_MINUTE_LIMIT = 90; // 1 minute 30s + const MINUTES_LIMIT = 2670; // 44 minutes 30s + const ONE_HOUR_LIMIT = 5370; // 89 minutes 30s + const HOURS_LIMIT = 86370; // 23 hours 59 minutes 30s + const ONE_DAY_LIMIT = 151170; // 41 hours 59 minutes 30s + + const { days = 0, hours = 0, minutes = 0 } = parseSeconds(seconds, { + daysPerWeek: 7, + hoursPerDay: 24, + limitToDays: true, + }); + + if (seconds < 30) { + return __('less than a minute'); + } else if (seconds < MINUTES_LIMIT) { + return n__('1 minute', '%d minutes', seconds < ONE_MINUTE_LIMIT ? 1 : minutes); + } else if (seconds < HOURS_LIMIT) { + return n__('about 1 hour', 'about %d hours', seconds < ONE_HOUR_LIMIT ? 1 : hours); + } + return n__('1 day', '%d days', seconds < ONE_DAY_LIMIT ? 1 : days); +}; diff --git a/app/assets/javascripts/lib/utils/keycodes.js b/app/assets/javascripts/lib/utils/keycodes.js index 5e0f9b612a2..2270d329c24 100644 --- a/app/assets/javascripts/lib/utils/keycodes.js +++ b/app/assets/javascripts/lib/utils/keycodes.js @@ -2,3 +2,4 @@ export const UP_KEY_CODE = 38; export const DOWN_KEY_CODE = 40; export const ENTER_KEY_CODE = 13; export const ESC_KEY_CODE = 27; +export const BACKSPACE_KEY_CODE = 8; diff --git a/app/assets/javascripts/lib/utils/poll_until_complete.js b/app/assets/javascripts/lib/utils/poll_until_complete.js new file mode 100644 index 00000000000..199d0e6f0f7 --- /dev/null +++ b/app/assets/javascripts/lib/utils/poll_until_complete.js @@ -0,0 +1,42 @@ +import axios from '~/lib/utils/axios_utils'; +import Poll from './poll'; +import httpStatusCodes from './http_status'; + +/** + * Polls an endpoint until it returns either a 200 OK or a error status. + * The Poll-Interval header in the responses are used to determine how + * frequently to poll. + * + * Once a 200 OK is received, the promise resolves with that response. If an + * error status is received, the promise rejects with the error. + * + * @param {string} url - The URL to poll. + * @param {Object} [config] - The config to provide to axios.get(). + * @returns {Promise} + */ +export default (url, config = {}) => + new Promise((resolve, reject) => { + const eTagPoll = new Poll({ + resource: { + axiosGet(data) { + return axios.get(data.url, { + headers: { + 'Content-Type': 'application/json', + }, + ...data.config, + }); + }, + }, + data: { url, config }, + method: 'axiosGet', + successCallback: response => { + if (response.status === httpStatusCodes.OK) { + resolve(response); + eTagPoll.stop(); + } + }, + errorCallback: reject, + }); + + eTagPoll.makeRequest(); + }); diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 6bbf118d7d1..a03fedcd7e7 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -21,12 +21,17 @@ export const addDelimiter = text => export const highCountTrim = count => (count > 99 ? '99+' : count); /** - * Converts first char to uppercase and replaces undercores with spaces - * @param {String} string + * Converts first char to uppercase and replaces the given separator with spaces + * @param {String} string - The string to humanize + * @param {String} separator - The separator used to separate words (defaults to "_") * @requires {String} + * @returns {String} */ -export const humanize = string => - string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); +export const humanize = (string, separator = '_') => { + const replaceRegex = new RegExp(separator, 'g'); + + return string.charAt(0).toUpperCase() + string.replace(replaceRegex, ' ').slice(1); +}; /** * Replaces underscores with dashes @@ -45,7 +50,11 @@ export const slugify = (str, separator = '-') => { const slug = str .trim() .toLowerCase() - .replace(/[^a-zA-Z0-9_.-]+/g, separator); + .replace(/[^a-zA-Z0-9_.-]+/g, separator) + // Remove any duplicate separators or separator prefixes/suffixes + .split(separator) + .filter(Boolean) + .join(separator); return slug === separator ? '' : slug; }; @@ -160,6 +169,15 @@ export const convertToSentenceCase = string => { }; /** + * Converts a sentence to title case + * e.g. Hello world => Hello World + * + * @param {String} string + * @returns {String} + */ +export const convertToTitleCase = string => string.replace(/\b[a-z]/g, s => s.toUpperCase()); + +/** * Splits camelCase or PascalCase words * e.g. HelloWorld => Hello World * diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 674415c9d01..d755e7e8cdb 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -19,7 +19,7 @@ import { getLocationHash, visitUrl } from './lib/utils/url_utility'; // everything else import loadAwardsHandler from './awards_handler'; -import bp from './breakpoints'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Flash, { removeFlashClickListener } from './flash'; import './gl_dropdown'; import initTodoToggle from './header'; @@ -55,9 +55,18 @@ jQuery.ajaxSetup({ }, }); +function disableJQueryAnimations() { + $.fx.off = true; +} + +// Disable jQuery animations +if (gon && gon.disable_animations) { + disableJQueryAnimations(); +} + // inject test utilities if necessary if (process.env.NODE_ENV !== 'production' && gon && gon.test_env) { - $.fx.off = true; + disableJQueryAnimations(); import(/* webpackMode: "eager" */ './test_utils/'); } @@ -113,6 +122,7 @@ function deferredInitialisation() { }); $('.js-remove-tr').on('ajax:success', function removeTRAjaxSuccessCallback() { + // eslint-disable-next-line no-jquery/no-fade $(this) .closest('tr') .fadeOut(); @@ -184,7 +194,7 @@ document.addEventListener('DOMContentLoaded', () => { } }); - if (bootstrapBreakpoint === 'xs') { + if (bootstrapBreakpoint === 'sm' || bootstrapBreakpoint === 'xs') { const $rightSidebar = $('aside.right-sidebar, .layout-page'); $rightSidebar.removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); @@ -212,7 +222,7 @@ document.addEventListener('DOMContentLoaded', () => { // Disable form buttons while a form is submitting $body.on('ajax:complete, ajax:beforeSend, submit', 'form', function ajaxCompleteCallback(e) { - const $buttons = $('[type="submit"], .js-disable-on-submit', this); + const $buttons = $('[type="submit"], .js-disable-on-submit', this).not('.js-no-auto-disable'); switch (e.type) { case 'ajax:beforeSend': case 'submit': @@ -269,7 +279,8 @@ document.addEventListener('DOMContentLoaded', () => { }); $document.on('breakpoint:change', (e, breakpoint) => { - if (breakpoint === 'sm' || breakpoint === 'xs') { + const breakpointSizes = ['md', 'sm', 'xs']; + if (breakpointSizes.includes(breakpoint)) { const $gutterIcon = $sidebarGutterToggle.find('i'); if ($gutterIcon.hasClass('fa-angle-double-right')) { $sidebarGutterToggle.trigger('click'); diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 52674107df2..96c4741fc2e 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -2,12 +2,12 @@ import $ from 'jquery'; import Vue from 'vue'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Cookies from 'js-cookie'; import axios from './lib/utils/axios_utils'; import flash from './flash'; import BlobForkSuggestion from './blob/blob_fork_suggestion'; import initChangesDropdown from './init_changes_dropdown'; -import bp from './breakpoints'; import { parseUrlPathname, handleLocationHash, @@ -194,7 +194,7 @@ export default class MergeRequestTabs { if (!isInVueNoteablePage()) { this.loadDiff(href); } - if (bp.getBreakpointSize() !== 'lg') { + if (bp.getBreakpointSize() !== 'xl') { this.shrinkView(); } this.expandViewContainer(); diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index 1738dbe439c..d15e4ecb537 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -52,6 +52,7 @@ export default class MilestoneSelect { const $block = $selectBox.closest('.block'); const $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon'); const $value = $block.find('.value'); + // eslint-disable-next-line no-jquery/no-fade const $loading = $block.find('.block-loading').fadeOut(); selectedMilestoneDefault = showAny ? '' : null; selectedMilestoneDefault = @@ -202,15 +203,18 @@ export default class MilestoneSelect { } $dropdown.trigger('loading.gl.dropdown'); + // eslint-disable-next-line no-jquery/no-fade $loading.removeClass('hidden').fadeIn(); boardsStore.detail.issue .update($dropdown.attr('data-issue-update')) .then(() => { $dropdown.trigger('loaded.gl.dropdown'); + // eslint-disable-next-line no-jquery/no-fade $loading.fadeOut(); }) .catch(() => { + // eslint-disable-next-line no-jquery/no-fade $loading.fadeOut(); }); } else { @@ -218,12 +222,14 @@ export default class MilestoneSelect { data = {}; data[abilityName] = {}; data[abilityName].milestone_id = selected != null ? selected : null; + // eslint-disable-next-line no-jquery/no-fade $loading.removeClass('hidden').fadeIn(); $dropdown.trigger('loading.gl.dropdown'); return axios .put(issueUpdateURL, data) .then(({ data }) => { $dropdown.trigger('loaded.gl.dropdown'); + // eslint-disable-next-line no-jquery/no-fade $loading.fadeOut(); $selectBox.hide(); $value.css('display', ''); @@ -247,6 +253,7 @@ export default class MilestoneSelect { } }) .catch(() => { + // eslint-disable-next-line no-jquery/no-fade $loading.fadeOut(); }); } diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue index 1df7ca37a98..64704701d1a 100644 --- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue +++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue @@ -1,6 +1,6 @@ <script> import { flatten, isNumber } from 'underscore'; -import { GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; +import { GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import { roundOffFloat } from '~/lib/utils/common_utils'; import { hexToRgb } from '~/lib/utils/color_utils'; import { areaOpacityValues, symbolSizes, colorValues } from '../../constants'; @@ -48,7 +48,6 @@ const AREA_COLOR_RGBA = `rgba(${hexToRgb(AREA_COLOR).join(',')},${AREA_OPACITY}) */ export default { components: { - GlLineChart, GlChartSeriesLabel, MonitorTimeSeriesChart, }, diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index c1ca5449ba3..b03ee12aef3 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -17,28 +17,35 @@ import createFlash from '~/flash'; import Icon from '~/vue_shared/components/icon.vue'; import { getParameterValues, mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; import invalidUrl from '~/lib/utils/invalid_url'; + import DateTimePicker from './date_time_picker/date_time_picker.vue'; import GraphGroup from './graph_group.vue'; import EmptyState from './empty_state.vue'; import GroupEmptyState from './group_empty_state.vue'; +import DashboardsDropdown from './dashboards_dropdown.vue'; + import TrackEventDirective from '~/vue_shared/directives/track_event'; -import { getTimeDiff, isValidDate, getAddMetricTrackingOptions } from '../utils'; +import { getTimeDiff, getAddMetricTrackingOptions } from '../utils'; import { metricStates } from '../constants'; +const defaultTimeDiff = getTimeDiff(); + export default { components: { VueDraggable, PanelType, - GraphGroup, - EmptyState, - GroupEmptyState, Icon, GlButton, GlDropdown, GlDropdownItem, GlFormGroup, GlModal, + DateTimePicker, + GraphGroup, + EmptyState, + GroupEmptyState, + DashboardsDropdown, }, directives: { GlModal: GlModalDirective, @@ -81,6 +88,10 @@ export default { type: String, required: true, }, + defaultBranch: { + type: String, + required: true, + }, metricsEndpoint: { type: String, required: true, @@ -138,6 +149,11 @@ export default { required: false, default: invalidUrl, }, + dashboardsEndpoint: { + type: String, + required: false, + default: invalidUrl, + }, currentDashboard: { type: String, required: false, @@ -168,9 +184,10 @@ export default { return { state: 'gettingStarted', formIsValid: null, - selectedTimeWindow: {}, - isRearrangingPanels: false, + startDate: getParameterValues('start')[0] || defaultTimeDiff.start, + endDate: getParameterValues('end')[0] || defaultTimeDiff.end, hasValidDates: true, + isRearrangingPanels: false, }; }, computed: { @@ -196,9 +213,6 @@ export default { selectedDashboard() { return this.allDashboards.find(d => d.path === this.currentDashboard) || this.firstDashboard; }, - selectedDashboardText() { - return this.selectedDashboard.display_name; - }, showRearrangePanelsBtn() { return !this.showEmptyState && this.rearrangePanelsAvailable; }, @@ -220,6 +234,7 @@ export default { environmentsEndpoint: this.environmentsEndpoint, deploymentsEndpoint: this.deploymentsEndpoint, dashboardEndpoint: this.dashboardEndpoint, + dashboardsEndpoint: this.dashboardsEndpoint, currentDashboard: this.currentDashboard, projectPath: this.projectPath, }); @@ -228,24 +243,10 @@ export default { if (!this.hasMetrics) { this.setGettingStartedEmptyState(); } else { - const defaultRange = getTimeDiff(); - const start = getParameterValues('start')[0] || defaultRange.start; - const end = getParameterValues('end')[0] || defaultRange.end; - - const range = { - start, - end, - }; - - this.selectedTimeWindow = range; - - if (!isValidDate(start) || !isValidDate(end)) { - this.hasValidDates = false; - this.showInvalidDateError(); - } else { - this.hasValidDates = true; - this.fetchData(range); - } + this.fetchData({ + start: this.startDate, + end: this.endDate, + }); } }, methods: { @@ -267,9 +268,20 @@ export default { key, }); }, - showInvalidDateError() { - createFlash(s__('Metrics|Link contains an invalid time window.')); + + onDateTimePickerApply(params) { + redirectTo(mergeUrlParams(params, window.location.href)); + }, + onDateTimePickerInvalid() { + createFlash( + s__( + 'Metrics|Link contains an invalid time window, please verify the link to see the requested time range.', + ), + ); + this.startDate = defaultTimeDiff.start; + this.endDate = defaultTimeDiff.end; }, + generateLink(group, title, yLabel) { const dashboard = this.currentDashboard || this.firstDashboard.path; const params = _.pick({ dashboard, group, title, y_label: yLabel }, value => value != null); @@ -287,9 +299,6 @@ export default { submitCustomMetricsForm() { this.$refs.customMetricsForm.submit(); }, - onDateTimePickerApply(timeWindowUrlParams) { - return redirectTo(mergeUrlParams(timeWindowUrlParams, window.location.href)); - }, /** * Return a single empty state for a group. * @@ -317,6 +326,13 @@ export default { return !this.getMetricStates(groupKey).includes(metricStates.OK); }, getAddMetricTrackingOptions, + + selectDashboard(dashboard) { + const params = { + dashboard: dashboard.path, + }; + redirectTo(mergeUrlParams(params, window.location.href)); + }, }, addMetric: { title: s__('Metrics|Add metric'), @@ -336,21 +352,14 @@ export default { label-for="monitor-dashboards-dropdown" class="col-sm-12 col-md-6 col-lg-2" > - <gl-dropdown + <dashboards-dropdown id="monitor-dashboards-dropdown" - class="mb-0 d-flex js-dashboards-dropdown" + class="mb-0 d-flex" 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> + :default-branch="defaultBranch" + :selected-dashboard="selectedDashboard" + @selectDashboard="selectDashboard($event)" + /> </gl-form-group> <gl-form-group @@ -378,15 +387,16 @@ export default { </gl-form-group> <gl-form-group - v-if="hasValidDates" :label="s__('Metrics|Show last')" label-size="sm" label-for="monitor-time-window-dropdown" class="col-sm-6 col-md-6 col-lg-4" > <date-time-picker - :selected-time-window="selectedTimeWindow" - @onApply="onDateTimePickerApply" + :start="startDate" + :end="endDate" + @apply="onDateTimePickerApply" + @invalid="onDateTimePickerInvalid" /> </gl-form-group> </template> diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue new file mode 100644 index 00000000000..6d93eee0b4f --- /dev/null +++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue @@ -0,0 +1,139 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { + GlAlert, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlModal, + GlLoadingIcon, + GlModalDirective, +} from '@gitlab/ui'; +import DuplicateDashboardForm from './duplicate_dashboard_form.vue'; + +const events = { + selectDashboard: 'selectDashboard', +}; + +export default { + components: { + GlAlert, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlModal, + GlLoadingIcon, + DuplicateDashboardForm, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + selectedDashboard: { + type: Object, + required: false, + default: () => ({}), + }, + defaultBranch: { + type: String, + required: true, + }, + }, + data() { + return { + alert: null, + loading: false, + form: {}, + }; + }, + computed: { + ...mapState('monitoringDashboard', ['allDashboards']), + isSystemDashboard() { + return this.selectedDashboard.system_dashboard; + }, + selectedDashboardText() { + return this.selectedDashboard.display_name; + }, + }, + methods: { + ...mapActions('monitoringDashboard', ['duplicateSystemDashboard']), + selectDashboard(dashboard) { + this.$emit(events.selectDashboard, dashboard); + }, + ok(bvModalEvt) { + // Prevent modal from hiding in case submit fails + bvModalEvt.preventDefault(); + + this.loading = true; + this.alert = null; + this.duplicateSystemDashboard(this.form) + .then(createdDashboard => { + this.loading = false; + this.alert = null; + + // Trigger hide modal as submit is successful + this.$refs.duplicateDashboardModal.hide(); + + // Dashboards in the default branch become available immediately. + // Not so in other branches, so we refresh the current dashboard + const dashboard = + this.form.branch === this.defaultBranch ? createdDashboard : this.selectedDashboard; + this.$emit(events.selectDashboard, dashboard); + }) + .catch(error => { + this.loading = false; + this.alert = error; + }); + }, + hide() { + this.alert = null; + }, + formChange(form) { + this.form = form; + }, + }, +}; +</script> +<template> + <gl-dropdown toggle-class="dropdown-menu-toggle" :text="selectedDashboardText"> + <gl-dropdown-item + v-for="dashboard in allDashboards" + :key="dashboard.path" + :active="dashboard.path === selectedDashboard.path" + active-class="is-active" + @click="selectDashboard(dashboard)" + > + {{ dashboard.display_name || dashboard.path }} + </gl-dropdown-item> + + <template v-if="isSystemDashboard"> + <gl-dropdown-divider /> + + <gl-modal + ref="duplicateDashboardModal" + modal-id="duplicateDashboardModal" + :title="s__('Metrics|Duplicate dashboard')" + ok-variant="success" + @ok="ok" + @hide="hide" + > + <gl-alert v-if="alert" class="mb-3" variant="danger" @dismiss="alert = null"> + {{ alert }} + </gl-alert> + <duplicate-dashboard-form + :dashboard="selectedDashboard" + :default-branch="defaultBranch" + @change="formChange" + /> + <template #modal-ok> + <gl-loading-icon v-if="loading" inline color="light" /> + {{ loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate') }} + </template> + </gl-modal> + + <gl-dropdown-item ref="duplicateDashboardItem" v-gl-modal="'duplicateDashboardModal'"> + {{ s__('Metrics|Duplicate dashboard') }} + </gl-dropdown-item> + </template> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue index 8749019c5cd..0aa710b1b3a 100644 --- a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue +++ b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue @@ -5,14 +5,21 @@ import Icon from '~/vue_shared/components/icon.vue'; import DateTimePickerInput from './date_time_picker_input.vue'; import { getTimeDiff, + isValidDate, getTimeWindow, stringToISODate, ISODateToString, truncateZerosInDateTime, isDateTimePickerInputValid, } from '~/monitoring/utils'; + import { timeWindows } from '~/monitoring/constants'; +const events = { + apply: 'apply', + invalid: 'invalid', +}; + export default { components: { Icon, @@ -23,77 +30,94 @@ export default { GlDropdownItem, }, props: { + start: { + type: String, + required: true, + }, + end: { + type: String, + required: true, + }, timeWindows: { type: Object, required: false, default: () => timeWindows, }, - selectedTimeWindow: { - type: Object, - required: false, - default: () => {}, - }, }, data() { return { - selectedTimeWindowText: '', - customTime: { - from: null, - to: null, - }, + startDate: this.start, + endDate: this.end, }; }, computed: { - applyEnabled() { - return Boolean(this.inputState.from && this.inputState.to); + startInputValid() { + return isValidDate(this.startDate); }, - inputState() { - const { from, to } = this.customTime; - return { - from: from && isDateTimePickerInputValid(from), - to: to && isDateTimePickerInputValid(to), - }; + endInputValid() { + return isValidDate(this.endDate); }, - }, - watch: { - selectedTimeWindow() { - this.verifyTimeRange(); + isValid() { + return this.startInputValid && this.endInputValid; + }, + + startInput: { + get() { + return this.startInputValid ? this.formatDate(this.startDate) : this.startDate; + }, + set(val) { + // Attempt to set a formatted date if possible + this.startDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val; + }, + }, + endInput: { + get() { + return this.endInputValid ? this.formatDate(this.endDate) : this.endDate; + }, + set(val) { + // Attempt to set a formatted date if possible + this.endDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val; + }, + }, + + timeWindowText() { + const timeWindow = getTimeWindow({ start: this.start, end: this.end }); + if (timeWindow) { + return this.timeWindows[timeWindow]; + } else if (isValidDate(this.start) && isValidDate(this.end)) { + return sprintf(s__('%{start} to %{end}'), { + start: this.formatDate(this.start), + end: this.formatDate(this.end), + }); + } + return ''; }, }, mounted() { - this.verifyTimeRange(); + // Validate on mounted, and trigger an update if needed + if (!this.isValid) { + this.$emit(events.invalid); + } }, methods: { - activeTimeWindow(key) { - return this.timeWindows[key] === this.selectedTimeWindowText; + formatDate(date) { + return truncateZerosInDateTime(ISODateToString(date)); }, - setCustomTimeWindowParameter() { - this.$emit('onApply', { - start: stringToISODate(this.customTime.from), - end: stringToISODate(this.customTime.to), - }); - }, - setTimeWindowParameter(key) { + setTimeWindow(key) { const { start, end } = getTimeDiff(key); - this.$emit('onApply', { - start, - end, - }); + this.startDate = start; + this.endDate = end; + + this.apply(); }, closeDropdown() { this.$refs.dropdown.hide(); }, - verifyTimeRange() { - const range = getTimeWindow(this.selectedTimeWindow); - if (range) { - this.selectedTimeWindowText = this.timeWindows[range]; - } else { - this.customTime = { - from: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.start)), - to: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.end)), - }; - this.selectedTimeWindowText = sprintf(s__('%{from} to %{to}'), this.customTime); - } + apply() { + this.$emit(events.apply, { + start: this.startDate, + end: this.endDate, + }); }, }, }; @@ -101,7 +125,7 @@ export default { <template> <gl-dropdown ref="dropdown" - :text="selectedTimeWindowText" + :text="timeWindowText" menu-class="time-window-dropdown-menu" class="js-time-window-dropdown" > @@ -113,24 +137,21 @@ export default { > <date-time-picker-input id="custom-time-from" - v-model="customTime.from" + v-model="startInput" :label="__('From')" - :state="inputState.from" + :state="startInputValid" /> <date-time-picker-input id="custom-time-to" - v-model="customTime.to" + v-model="endInput" :label="__('To')" - :state="inputState.to" + :state="endInputValid" /> <gl-form-group> <gl-button @click="closeDropdown">{{ __('Cancel') }}</gl-button> - <gl-button - variant="success" - :disabled="!applyEnabled" - @click="setCustomTimeWindowParameter" - >{{ __('Apply') }}</gl-button - > + <gl-button variant="success" :disabled="!isValid" @click="apply()"> + {{ __('Apply') }} + </gl-button> </gl-form-group> </gl-form-group> <gl-form-group @@ -142,14 +163,14 @@ export default { <gl-dropdown-item v-for="(value, key) in timeWindows" :key="key" - :active="activeTimeWindow(key)" + :active="value === timeWindowText" active-class="active" - @click="setTimeWindowParameter(key)" + @click="setTimeWindow(key)" > <icon name="mobile-issue-close" class="align-bottom" - :class="{ invisible: !activeTimeWindow(key) }" + :class="{ invisible: value !== timeWindowText }" /> {{ value }} </gl-dropdown-item> diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue new file mode 100644 index 00000000000..e678957c1e5 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue @@ -0,0 +1,138 @@ +<script> +import { __, s__, sprintf } from '~/locale'; +import { GlFormGroup, GlFormInput, GlFormRadioGroup, GlFormTextarea } from '@gitlab/ui'; + +const defaultFileName = dashboard => dashboard.path.split('/').reverse()[0]; + +export default { + components: { + GlFormGroup, + GlFormInput, + GlFormRadioGroup, + GlFormTextarea, + }, + props: { + dashboard: { + type: Object, + required: true, + }, + defaultBranch: { + type: String, + required: true, + }, + }, + radioVals: { + /* Use the default branch (e.g. master) */ + DEFAULT: 'DEFAULT', + /* Create a new branch */ + NEW: 'NEW', + }, + data() { + return { + form: { + dashboard: this.dashboard.path, + fileName: defaultFileName(this.dashboard), + commitMessage: '', + }, + branchName: '', + branchOption: this.$options.radioVals.NEW, + branchOptions: [ + { + value: this.$options.radioVals.DEFAULT, + html: sprintf( + __('Commit to %{branchName} branch'), + { + branchName: `<strong>${this.defaultBranch}</strong>`, + }, + false, + ), + }, + { value: this.$options.radioVals.NEW, text: __('Create new branch') }, + ], + }; + }, + computed: { + defaultCommitMsg() { + return sprintf(s__('Metrics|Create custom dashboard %{fileName}'), { + fileName: this.form.fileName, + }); + }, + fileNameState() { + // valid if empty or *.yml + return !(this.form.fileName && !this.form.fileName.endsWith('.yml')); + }, + fileNameFeedback() { + return !this.fileNameState ? s__('The file name should have a .yml extension') : ''; + }, + }, + mounted() { + this.change(); + }, + methods: { + change() { + this.$emit('change', { + ...this.form, + commitMessage: this.form.commitMessage || this.defaultCommitMsg, + branch: + this.branchOption === this.$options.radioVals.NEW ? this.branchName : this.defaultBranch, + }); + }, + focus(option) { + if (option === this.$options.radioVals.NEW) { + this.$nextTick(() => { + this.$refs.branchName.$el.focus(); + }); + } + }, + }, +}; +</script> +<template> + <form @change="change"> + <p class="text-muted"> + {{ + s__(`Metrics|You can save a copy of this dashboard to your repository + so it can be customized. Select a file name and branch to + save it.`) + }} + </p> + <gl-form-group + ref="fileNameFormGroup" + :label="__('File name')" + :state="fileNameState" + :invalid-feedback="fileNameFeedback" + label-size="sm" + label-for="fileName" + > + <gl-form-input id="fileName" ref="fileName" v-model="form.fileName" :required="true" /> + </gl-form-group> + <gl-form-group :label="__('Branch')" label-size="sm" label-for="branch"> + <gl-form-radio-group + ref="branchOption" + v-model="branchOption" + :checked="$options.radioVals.NEW" + :stacked="true" + :options="branchOptions" + @change="focus" + /> + <gl-form-input + v-show="branchOption === $options.radioVals.NEW" + id="branchName" + ref="branchName" + v-model="branchName" + /> + </gl-form-group> + <gl-form-group + :label="__('Commit message (optional)')" + label-size="sm" + label-for="commitMessage" + > + <gl-form-textarea + id="commitMessage" + ref="commitMessage" + v-model="form.commitMessage" + :placeholder="defaultCommitMsg" + /> + </gl-form-group> + </form> +</template> diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue index eb8945c1a57..2f562071764 100644 --- a/app/assets/javascripts/monitoring/components/embed.vue +++ b/app/assets/javascripts/monitoring/components/embed.vue @@ -2,7 +2,6 @@ import { mapActions, mapState, mapGetters } from 'vuex'; import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; import { getParameterValues, removeParams } from '~/lib/utils/url_utility'; -import GraphGroup from './graph_group.vue'; import { sidebarAnimationDuration } from '../constants'; import { getTimeDiff } from '../utils'; @@ -10,7 +9,6 @@ let sidebarMutationObserver; export default { components: { - GraphGroup, PanelType, }, props: { diff --git a/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue b/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue index 153c8f389db..ceeec51ee65 100644 --- a/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue +++ b/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue @@ -10,6 +10,6 @@ export default { </script> <template> <div class="prometheus-graph-header"> - <h5 class="prometheus-graph-title js-graph-title">{{ graphTitle }}</h5> + <h5 ref="title" class="prometheus-graph-title">{{ graphTitle }}</h5> </div> </template> diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 1cb82ce0083..61cd8621902 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -39,7 +39,7 @@ export const requestMetricsDashboard = ({ commit }) => { }; export const receiveMetricsDashboardSuccess = ({ commit, dispatch }, { response, params }) => { commit(types.SET_ALL_DASHBOARDS, response.all_dashboards); - commit(types.RECEIVE_METRICS_DATA_SUCCESS, response.dashboard.panel_groups); + commit(types.RECEIVE_METRICS_DATA_SUCCESS, response.dashboard); return dispatch('fetchPrometheusMetrics', params); }; export const receiveMetricsDashboardFailure = ({ commit }, error) => { @@ -214,5 +214,29 @@ export const setPanelGroupMetrics = ({ commit }, data) => { commit(types.SET_PANEL_GROUP_METRICS, data); }; +export const duplicateSystemDashboard = ({ state }, payload) => { + const params = { + dashboard: payload.dashboard, + file_name: payload.fileName, + branch: payload.branch, + commit_message: payload.commitMessage, + }; + + return axios + .post(state.dashboardsEndpoint, params) + .then(response => response.data) + .then(data => data.dashboard) + .catch(error => { + const { response } = error; + if (response && response.data && response.data.error) { + throw sprintf(s__('Metrics|There was an error creating the dashboard. %{error}'), { + error: response.data.error, + }); + } else { + throw s__('Metrics|There was an error creating the dashboard.'); + } + }); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index 16a34a6c026..506a30ae619 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -84,23 +84,26 @@ export default { state.emptyState = 'loading'; state.showEmptyState = true; }, - [types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) { - state.dashboard.panel_groups = groupData.map((group, i) => { - const key = `${slugify(group.group || 'default')}-${i}`; - let { panels = [] } = group; - - // each panel has metric information that needs to be normalized - panels = panels.map(panel => ({ - ...panel, - metrics: normalizePanelMetrics(panel.metrics, panel.y_label), - })); - - return { - ...group, - panels, - key, - }; - }); + [types.RECEIVE_METRICS_DATA_SUCCESS](state, dashboard) { + state.dashboard = { + ...dashboard, + panel_groups: dashboard.panel_groups.map((group, i) => { + const key = `${slugify(group.group || 'default')}-${i}`; + let { panels = [] } = group; + + // each panel has metric information that needs to be normalized + panels = panels.map(panel => ({ + ...panel, + metrics: normalizePanelMetrics(panel.metrics, panel.y_label), + })); + + return { + ...group, + panels, + key, + }; + }), + }; if (!state.dashboard.panel_groups.length) { state.emptyState = 'noData'; @@ -172,6 +175,7 @@ export default { state.environmentsEndpoint = endpoints.environmentsEndpoint; state.deploymentsEndpoint = endpoints.deploymentsEndpoint; state.dashboardEndpoint = endpoints.dashboardEndpoint; + state.dashboardsEndpoint = endpoints.dashboardsEndpoint; state.currentDashboard = endpoints.currentDashboard; state.projectPath = endpoints.projectPath; }, diff --git a/app/assets/javascripts/mr_popover/components/mr_popover.vue b/app/assets/javascripts/mr_popover/components/mr_popover.vue index ce08b0964a1..bbc2feae812 100644 --- a/app/assets/javascripts/mr_popover/components/mr_popover.vue +++ b/app/assets/javascripts/mr_popover/components/mr_popover.vue @@ -1,7 +1,6 @@ <script> /* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { GlPopover, GlSkeletonLoading } from '@gitlab/ui'; -import Icon from '../../vue_shared/components/icon.vue'; import CiIcon from '../../vue_shared/components/ci_icon.vue'; import timeagoMixin from '../../vue_shared/mixins/timeago'; import query from '../queries/merge_request.query.graphql'; @@ -13,7 +12,6 @@ export default { components: { GlPopover, GlSkeletonLoading, - Icon, CiIcon, }, mixins: [timeagoMixin], diff --git a/app/assets/javascripts/mr_tabs_popover/components/popover.vue b/app/assets/javascripts/mr_tabs_popover/components/popover.vue index da1e1e70993..f8293d2a473 100644 --- a/app/assets/javascripts/mr_tabs_popover/components/popover.vue +++ b/app/assets/javascripts/mr_tabs_popover/components/popover.vue @@ -57,7 +57,12 @@ export default { <icon name="external-link" :size="10" /> </gl-link> </p> - <gl-button variant="primary" size="sm" @click="onDismiss"> + <gl-button + variant="primary" + size="sm" + data-qa-selector="dismiss_popover_button" + @click="onDismiss" + > {{ __('Got it') }} </gl-button> </gl-popover> diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 1a8f1c659a4..4195ea6425f 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1359,7 +1359,8 @@ export default class Notes { const $systemNote = $(systemNote); const headerMessage = $systemNote .find('.note-text') - .find('p:first') + .find('p') + .first() .text() .replace(':', ''); diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 492d8de3802..4ca32b9b005 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -336,6 +336,7 @@ export default { <markdown-field ref="markdownField" + :is-submitting="isSubmitting" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" :quick-actions-docs-path="quickActionsDocsPath" diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 1f31720ff40..3462ee72dd3 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -89,6 +89,9 @@ export default { currentUser() { return this.getUserData; }, + isLoggedIn() { + return Boolean(gon.current_user_id); + }, autosaveKey() { return getDiscussionReplyKey(this.firstNote.noteable_type, this.discussion.id); }, @@ -314,7 +317,7 @@ export default { @cancelForm="cancelReplyForm" /> </div> - <note-signed-out-widget v-if="!userCanReply" /> + <note-signed-out-widget v-if="!isLoggedIn" /> </div> </template> </discussion-notes> diff --git a/app/assets/javascripts/notifications_dropdown.js b/app/assets/javascripts/notifications_dropdown.js index 08545dcea46..ab87b0d973c 100644 --- a/app/assets/javascripts/notifications_dropdown.js +++ b/app/assets/javascripts/notifications_dropdown.js @@ -11,7 +11,9 @@ export default function notificationsDropdown() { } const notificationLevel = $(this).data('notificationLevel'); - const form = $(this).parents('.notification-form:first'); + const form = $(this) + .parents('.notification-form') + .first(); form.find('.js-notification-loading').toggleClass('fa-bell fa-spin fa-spinner'); if (form.hasClass('no-label')) { diff --git a/app/assets/javascripts/notifications_form.js b/app/assets/javascripts/notifications_form.js index 45f033f2822..dcd226795a6 100644 --- a/app/assets/javascripts/notifications_form.js +++ b/app/assets/javascripts/notifications_form.js @@ -31,7 +31,7 @@ export default class NotificationsForm { } saveEvent($checkbox, $parent) { - const form = $parent.parents('form:first'); + const form = $parent.parents('form').first(); this.showCheckboxLoadingSpinner($parent); diff --git a/app/assets/javascripts/pages/admin/admin.js b/app/assets/javascripts/pages/admin/admin.js index 4616a075729..88967d82b2f 100644 --- a/app/assets/javascripts/pages/admin/admin.js +++ b/app/assets/javascripts/pages/admin/admin.js @@ -36,6 +36,8 @@ export default function adminInit() { $('.log-bottom').on('click', e => { e.preventDefault(); const $visibleLog = $('.file-content:visible'); + + // eslint-disable-next-line no-jquery/no-animate $visibleLog.animate( { scrollTop: $visibleLog.find('ol').height(), diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js index 47bd70537f1..089dedd14cb 100644 --- a/app/assets/javascripts/pages/admin/application_settings/index.js +++ b/app/assets/javascripts/pages/admin/application_settings/index.js @@ -1,7 +1,11 @@ import initSettingsPanels from '~/settings_panels'; import projectSelect from '~/project_select'; +import selfMonitor from '~/self_monitor'; document.addEventListener('DOMContentLoaded', () => { + if (gon.features && gon.features.selfMonitoringProject) { + selfMonitor(); + } // Initialize expandable settings panels initSettingsPanels(); projectSelect(); diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js index 7a6a486f551..7c2008d9edc 100644 --- a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js +++ b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js @@ -6,21 +6,31 @@ import { __ } from '~/locale'; import { textColorForBackground } from '~/lib/utils/color_utils'; export default () => { - const $broadcastMessageColor = $('input#broadcast_message_color'); - const $broadcastMessagePreview = $('div.broadcast-message-preview'); + const $broadcastMessageColor = $('.js-broadcast-message-color'); + const $broadcastMessageType = $('.js-broadcast-message-type'); + const $broadcastBannerMessagePreview = $('.js-broadcast-banner-message-preview'); + const $broadcastMessage = $('.js-broadcast-message-message'); + const previewPath = $broadcastMessage.data('previewPath'); + const $jsBroadcastMessagePreview = $('.js-broadcast-message-preview'); + $broadcastMessageColor.on('input', function onMessageColorInput() { const previewColor = $(this).val(); - $broadcastMessagePreview.css('background-color', previewColor); + $broadcastBannerMessagePreview.css('background-color', previewColor); }); $('input#broadcast_message_font').on('input', function onMessageFontInput() { const previewColor = $(this).val(); - $broadcastMessagePreview.css('color', previewColor); + $broadcastBannerMessagePreview.css('color', previewColor); }); - const $broadcastMessage = $('textarea#broadcast_message_message'); - const previewPath = $broadcastMessage.data('previewPath'); - const $jsBroadcastMessagePreview = $('.js-broadcast-message-preview'); + $broadcastMessageType.on('change', () => { + const $broadcastMessageColorFormGroup = $('.js-broadcast-message-background-color-form-group'); + const $broadcastNotificationMessagePreview = $('.js-broadcast-notification-message-preview'); + + $broadcastMessageColorFormGroup.toggleClass('hidden'); + $broadcastBannerMessagePreview.toggleClass('hidden'); + $broadcastNotificationMessagePreview.toggleClass('hidden'); + }); $broadcastMessage.on( 'input', @@ -58,7 +68,7 @@ export default () => { $('.label-color-preview').css(selectedColorStyle); - return $broadcastMessagePreview.css(selectedColorStyle); + return $jsBroadcastMessagePreview.css(selectedColorStyle); }; const setSuggestedColor = e => { @@ -67,7 +77,10 @@ export default () => { .val(color) // Notify the form, that color has changed .trigger('input'); - updateColorPreview(); + // Only banner supports colors + if ($broadcastMessageType === 'banner') { + updateColorPreview(); + } return e.preventDefault(); }; diff --git a/app/assets/javascripts/pages/groups/group_members/index/index.js b/app/assets/javascripts/pages/groups/group_members/index/index.js index e77a7cf8e0a..0c732922e81 100644 --- a/app/assets/javascripts/pages/groups/group_members/index/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index/index.js @@ -3,9 +3,12 @@ import Members from 'ee_else_ce/members'; import memberExpirationDate from '~/member_expiration_date'; import UsersSelect from '~/users_select'; +import groupsSelect from '~/groups_select'; document.addEventListener('DOMContentLoaded', () => { memberExpirationDate(); + memberExpirationDate('.js-access-expiration-date-groups'); new Members(); + groupsSelect(); new UsersSelect(); }); diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js index 28a136a5fa5..75df80a0f6c 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/pages/projects/issues/show.js @@ -4,11 +4,13 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import ZenMode from '~/zen_mode'; import '~/notes/index'; import initIssueableApp from '~/issue_show'; +import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace'; import initRelatedMergeRequestsApp from '~/related_merge_requests'; import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle'; export default function() { initIssueableApp(); + initSentryErrorStackTraceApp(); initRelatedMergeRequestsApp(); new Issue(); // eslint-disable-line no-new new ShortcutsIssuable(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/pipelines/index/index.js b/app/assets/javascripts/pages/projects/pipelines/index/index.js index fd72d2ddbe0..4b4a274794d 100644 --- a/app/assets/javascripts/pages/projects/pipelines/index/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/index/index.js @@ -1,10 +1,18 @@ import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; +import { doesHashExistInUrl } from '~/lib/utils/url_utility'; +import { + parseBoolean, + historyReplaceState, + buildUrlWithCurrentLocation, +} from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; import PipelinesStore from '../../../../pipelines/stores/pipelines_store'; import pipelinesComponent from '../../../../pipelines/components/pipelines.vue'; import Translate from '../../../../vue_shared/translate'; -import { parseBoolean } from '../../../../lib/utils/common_utils'; Vue.use(Translate); +Vue.use(GlToast); document.addEventListener( 'DOMContentLoaded', @@ -21,6 +29,11 @@ document.addEventListener( }, created() { this.dataset = document.querySelector(this.$options.el).dataset; + + if (doesHashExistInUrl('delete_success')) { + this.$toast.show(__('The pipeline has been deleted')); + historyReplaceState(buildUrlWithCurrentLocation()); + } }, render(createElement) { return createElement('pipelines-component', { 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 4802cc2ad25..6994f83bce0 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 @@ -82,6 +82,11 @@ export default { required: false, default: false, }, + pagesAccessControlForced: { + type: Boolean, + required: false, + default: false, + }, pagesHelpPath: { type: String, required: false, @@ -99,6 +104,7 @@ export default { visibilityLevel: visibilityOptions.PUBLIC, issuesAccessLevel: 20, repositoryAccessLevel: 20, + forkingAccessLevel: 20, mergeRequestsAccessLevel: 20, buildsAccessLevel: 20, wikiAccessLevel: 20, @@ -130,10 +136,22 @@ export default { }, pagesFeatureAccessLevelOptions() { - if (this.visibilityLevel !== visibilityOptions.PUBLIC) { - return this.featureAccessLevelOptions.concat([[30, PAGE_FEATURE_ACCESS_LEVEL]]); + const options = [featureAccessLevelMembers]; + + if (this.pagesAccessControlForced) { + if (this.visibilityLevel === visibilityOptions.INTERNAL) { + options.push(featureAccessLevelEveryone); + } + } else { + if (this.visibilityLevel !== visibilityOptions.PRIVATE) { + options.push(featureAccessLevelEveryone); + } + + if (this.visibilityLevel !== visibilityOptions.PUBLIC) { + options.push([30, PAGE_FEATURE_ACCESS_LEVEL]); + } } - return this.featureAccessLevelOptions; + return options; }, repositoryEnabled() { @@ -284,6 +302,19 @@ export default { /> </project-setting-row> <project-setting-row + :label="s__('ProjectSettings|Forks')" + :help-text=" + s__('ProjectSettings|Allow users to make copies of your repository to a new project') + " + > + <project-feature-setting + v-model="forkingAccessLevel" + :options="featureAccessLevelOptions" + :disabled-input="!repositoryEnabled" + name="project[project_feature_attributes][forking_access_level]" + /> + </project-setting-row> + <project-setting-row :label="s__('ProjectSettings|Pipelines')" :help-text="s__('ProjectSettings|Build, test, and deploy your changes')" > diff --git a/app/assets/javascripts/pages/projects/wikis/wikis.js b/app/assets/javascripts/pages/projects/wikis/wikis.js index d41199f6374..80b62859134 100644 --- a/app/assets/javascripts/pages/projects/wikis/wikis.js +++ b/app/assets/javascripts/pages/projects/wikis/wikis.js @@ -1,4 +1,4 @@ -import bp from '../../../breakpoints'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { s__, sprintf } from '~/locale'; export default class Wikis { @@ -52,7 +52,7 @@ export default class Wikis { static sidebarCanCollapse() { const bootstrapBreakpoint = bp.getBreakpointSize(); - return bootstrapBreakpoint === 'xs'; + return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm'; } renderSidebar() { diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index 7ce32032ed3..24ae900b445 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -59,7 +59,8 @@ export default { <div v-if="currentRequest.details && metricDetails" :id="`peek-view-${metric}`" - class="view qa-performance-bar-detailed-metric" + class="view" + data-qa-selector="detailed_metric_content" > <button :data-target="`#modal-peek-${metric}-details`" 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 d17c2f33adc..1df5562e1b6 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -107,7 +107,11 @@ export default { </script> <template> <div id="js-peek" :class="env"> - <div v-if="currentRequest" class="d-flex container-fluid container-limited qa-performance-bar"> + <div + v-if="currentRequest" + class="d-flex container-fluid container-limited" + data-qa-selector="performance_bar" + > <div id="peek-view-host" class="view"> <span v-if="hasHost" diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue index 1610534ae0d..115b2ff08ac 100644 --- a/app/assets/javascripts/performance_bar/components/request_selector.vue +++ b/app/assets/javascripts/performance_bar/components/request_selector.vue @@ -45,13 +45,13 @@ export default { }; </script> <template> - <div id="peek-request-selector"> + <div id="peek-request-selector" data-qa-selector="request_dropdown"> <select v-model="currentRequestId"> <option v-for="request in requests" :key="request.id" :value="request.id" - class="qa-performance-bar-request" + data-qa-selector="request_dropdown_option" > {{ request.truncatedUrl }} <span v-if="request.hasWarnings">(!)</span> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 429122c8083..4dc6e51d2fc 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -43,7 +43,7 @@ export default { downstream: 'downstream', data() { return { - triggeredTopIndex: 1, + downstreamMarginTop: null, }; }, computed: { @@ -77,26 +77,34 @@ export default { expandedTriggered() { return this.pipeline.triggered && this.pipeline.triggered.find(el => el.isExpanded); }, - - /** - * Calculates the margin top of the clicked downstream pipeline by - * adding the height of each linked pipeline and the margin - */ - marginTop() { - return `${this.triggeredTopIndex * 52}px`; - }, pipelineTypeUpstream() { return this.type !== this.$options.downstream && this.expandedTriggeredBy; }, pipelineTypeDownstream() { return this.type !== this.$options.upstream && this.expandedTriggered; }, + pipelineProjectId() { + return this.pipeline.project.id; + }, }, methods: { - handleClickedDownstream(pipeline, clickedIndex) { - this.triggeredTopIndex = clickedIndex; + handleClickedDownstream(pipeline, clickedIndex, downstreamNode) { + /** + * Calculates the margin top of the clicked downstream pipeline by + * subtracting the clicked downstream pipelines offsetTop by it's parent's + * offsetTop and then subtracting either 15 (if child) or 30 (if not a child) + * due to the height of node and stage name margin bottom. + */ + this.downstreamMarginTop = this.calculateMarginTop( + downstreamNode, + downstreamNode.classList.contains('child-pipeline') ? 15 : 30, + ); + this.$emit('onClickTriggered', this.pipeline, pipeline); }, + calculateMarginTop(downstreamNode, pixelDiff) { + return `${downstreamNode.offsetTop - downstreamNode.offsetParent.offsetTop - pixelDiff}px`; + }, hasOnlyOneJob(stage) { return stage.groups.length === 1; }, @@ -139,6 +147,7 @@ export default { v-if="hasTriggeredBy" :linked-pipelines="triggeredByPipelines" :column-title="__('Upstream')" + :project-id="pipelineProjectId" graph-position="left" @linkedPipelineClick=" linkedPipeline => $emit('onClickTriggeredBy', pipeline, linkedPipeline) @@ -174,6 +183,7 @@ export default { v-if="hasTriggered" :linked-pipelines="triggeredPipelines" :column-title="__('Downstream')" + :project-id="pipelineProjectId" graph-position="right" @linkedPipelineClick="handleClickedDownstream" /> @@ -186,7 +196,7 @@ export default { :is-loading="false" :pipeline="expandedTriggered" :is-linked-pipeline="true" - :style="{ 'margin-top': marginTop }" + :style="{ 'margin-top': downstreamMarginTop }" :mediator="mediator" @onClickTriggered=" (parentPipeline, pipeline) => clickTriggeredPipeline(parentPipeline, pipeline) diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index 82335e71403..d929398b6dc 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -1,6 +1,7 @@ <script> import { GlLoadingIcon, GlTooltipDirective, GlButton } from '@gitlab/ui'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; +import { __ } from '~/locale'; export default { directives: { @@ -16,6 +17,14 @@ export default { type: Object, required: true, }, + projectId: { + type: Number, + required: true, + }, + columnTitle: { + type: String, + required: true, + }, }, computed: { tooltipText() { @@ -30,18 +39,45 @@ export default { projectName() { return this.pipeline.project.name; }, + parentPipeline() { + // Refactor string match when BE returns Upstream/Downstream indicators + return this.projectId === this.pipeline.project.id && this.columnTitle === __('Upstream'); + }, + childPipeline() { + // Refactor string match when BE returns Upstream/Downstream indicators + return this.projectId === this.pipeline.project.id && this.columnTitle === __('Downstream'); + }, + label() { + return this.parentPipeline ? __('Parent') : __('Child'); + }, + childTooltipText() { + return __('This pipeline was triggered by a parent pipeline'); + }, + parentTooltipText() { + return __('This pipeline triggered a child pipeline'); + }, + labelToolTipText() { + return this.label === __('Parent') ? this.parentTooltipText : this.childTooltipText; + }, }, methods: { onClickLinkedPipeline() { this.$root.$emit('bv::hide::tooltip', this.buttonId); - this.$emit('pipelineClicked'); + this.$emit('pipelineClicked', this.$refs.linkedPipeline); + }, + hideTooltips() { + this.$root.$emit('bv::hide::tooltip'); }, }, }; </script> <template> - <li class="linked-pipeline build"> + <li + ref="linkedPipeline" + class="linked-pipeline build" + :class="{ 'child-pipeline': childPipeline }" + > <gl-button :id="buttonId" v-gl-tooltip @@ -59,6 +95,15 @@ export default { class="js-linked-pipeline-status" /> <span class="str-truncated align-bottom"> {{ projectName }} • #{{ pipeline.id }} </span> + <div v-if="parentPipeline || childPipeline" class="parent-child-label-container"> + <span + v-gl-tooltip.bottom + :title="labelToolTipText" + class="badge badge-primary" + @mouseover="hideTooltips" + >{{ label }}</span + > + </div> </gl-button> </li> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue index 998519f9df1..e3429184c05 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -19,6 +19,10 @@ export default { type: String, required: true, }, + projectId: { + type: Number, + required: true, + }, }, computed: { columnClass() { @@ -28,10 +32,16 @@ export default { }; return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`; }, + // Refactor string match when BE returns Upstream/Downstream indicators isUpstream() { return this.columnTitle === __('Upstream'); }, }, + methods: { + onPipelineClick(downstreamNode, pipeline, index) { + this.$emit('linkedPipelineClick', pipeline, index, downstreamNode); + }, + }, }; </script> @@ -48,7 +58,9 @@ export default { 'left-connector': pipeline.isExpanded && graphPosition === 'left', }" :pipeline="pipeline" - @pipelineClicked="$emit('linkedPipelineClick', pipeline, index)" + :column-title="columnTitle" + :project-id="projectId" + @pipelineClicked="onPipelineClick($event, pipeline, index)" /> </ul> </div> diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index 39afa87afc3..726bba7f9f4 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -1,14 +1,17 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlModal } from '@gitlab/ui'; import ciHeader from '../../vue_shared/components/header_ci_component.vue'; import eventHub from '../event_hub'; import { __ } from '~/locale'; +const DELETE_MODAL_ID = 'pipeline-delete-modal'; + export default { name: 'PipelineHeaderSection', components: { ciHeader, GlLoadingIcon, + GlModal, }, props: { pipeline: { @@ -33,6 +36,11 @@ export default { shouldRenderContent() { return !this.isLoading && Object.keys(this.pipeline).length; }, + deleteModalConfirmationText() { + return __( + 'Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone.', + ); + }, }, watch: { @@ -42,6 +50,13 @@ export default { }, methods: { + onActionClicked(action) { + if (action.modal) { + this.$root.$emit('bv::show::modal', action.modal); + } else { + this.postAction(action); + } + }, postAction(action) { const index = this.actions.indexOf(action); @@ -49,6 +64,13 @@ export default { eventHub.$emit('headerPostAction', action); }, + deletePipeline() { + const index = this.actions.findIndex(action => action.modal === DELETE_MODAL_ID); + + this.$set(this.actions[index], 'isLoading', true); + + eventHub.$emit('headerDeleteAction', this.actions[index]); + }, getActions() { const actions = []; @@ -58,7 +80,6 @@ export default { label: __('Retry'), path: this.pipeline.retry_path, cssClass: 'js-retry-button btn btn-inverted-secondary', - type: 'button', isLoading: false, }); } @@ -68,7 +89,16 @@ export default { label: __('Cancel running'), path: this.pipeline.cancel_path, cssClass: 'js-btn-cancel-pipeline btn btn-danger', - type: 'button', + isLoading: false, + }); + } + + if (this.pipeline.delete_path) { + actions.push({ + label: __('Delete'), + path: this.pipeline.delete_path, + modal: DELETE_MODAL_ID, + cssClass: 'js-btn-delete-pipeline btn btn-danger btn-inverted', isLoading: false, }); } @@ -76,6 +106,7 @@ export default { return actions; }, }, + DELETE_MODAL_ID, }; </script> <template> @@ -88,8 +119,21 @@ export default { :user="pipeline.user" :actions="actions" item-name="Pipeline" - @actionClicked="postAction" + @actionClicked="onActionClicked" /> + <gl-loading-icon v-if="isLoading" :size="2" class="prepend-top-default append-bottom-default" /> + + <gl-modal + :modal-id="$options.DELETE_MODAL_ID" + :title="__('Delete pipeline')" + :ok-title="__('Delete pipeline')" + ok-variant="danger" + @ok="deletePipeline()" + > + <p> + {{ deleteModalConfirmationText }} + </p> + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/nav_controls.vue b/app/assets/javascripts/pipelines/components/nav_controls.vue index c6990683ec7..5e4147f8805 100644 --- a/app/assets/javascripts/pipelines/components/nav_controls.vue +++ b/app/assets/javascripts/pipelines/components/nav_controls.vue @@ -1,12 +1,11 @@ <script> -import { GlLink, GlButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import LoadingButton from '../../vue_shared/components/loading_button.vue'; export default { name: 'PipelineNavControls', components: { LoadingButton, - GlLink, GlButton, }, props: { diff --git a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue index 7c4e651373f..6ca96bbba5e 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue @@ -2,7 +2,6 @@ import _ from 'underscore'; import { GlLink } from '@gitlab/ui'; import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { s__, sprintf } from '~/locale'; @@ -15,7 +14,6 @@ export default { components: { GlModal: DeprecatedModal2, GlLink, - ClipboardButton, CiIcon, }, props: { diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue index 30c830d78f9..743c3ea271d 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue @@ -2,7 +2,6 @@ import { GlLink, GlTooltipDirective } from '@gitlab/ui'; import _ from 'underscore'; import { __, sprintf } from '~/locale'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import popover from '~/vue_shared/directives/popover'; const popoverTitle = sprintf( @@ -17,7 +16,6 @@ const popoverTitle = sprintf( export default { components: { - UserAvatarLink, GlLink, }, directives: { diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue index 28b2c706320..65c1f125b55 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue @@ -3,11 +3,13 @@ import { mapGetters } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; import store from '~/pipelines/stores/test_reports'; import { __ } from '~/locale'; +import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; export default { name: 'TestsSuiteTable', components: { Icon, + SmartVirtualList, }, store, props: { @@ -23,6 +25,8 @@ export default { return this.getSuiteTests.length > 0; }, }, + maxShownRows: 30, + typicalRowHeight: 75, }; </script> @@ -34,7 +38,7 @@ export default { </div> </div> - <div v-if="hasSuites" class="test-reports-table js-test-cases-table"> + <div v-if="hasSuites" class="test-reports-table append-bottom-default js-test-cases-table"> <div role="row" class="gl-responsive-table-row table-row-header font-weight-bold fgray"> <div role="rowheader" class="table-section section-20"> {{ __('Class') }} @@ -53,52 +57,58 @@ export default { </div> </div> - <div - v-for="(testCase, index) in getSuiteTests" - :key="index" - class="gl-responsive-table-row rounded align-items-md-start mt-sm-3 js-case-row" + <smart-virtual-list + :length="getSuiteTests.length" + :remain="$options.maxShownRows" + :size="$options.typicalRowHeight" > - <div class="table-section section-20 section-wrap"> - <div role="rowheader" class="table-mobile-header">{{ __('Class') }}</div> - <div class="table-mobile-content pr-md-1">{{ testCase.classname }}</div> - </div> + <div + v-for="(testCase, index) in getSuiteTests" + :key="index" + class="gl-responsive-table-row rounded align-items-md-start mt-xs-3 js-case-row" + > + <div class="table-section section-20 section-wrap"> + <div role="rowheader" class="table-mobile-header">{{ __('Class') }}</div> + <div class="table-mobile-content pr-md-1 text-truncate">{{ testCase.classname }}</div> + </div> - <div class="table-section section-20 section-wrap"> - <div role="rowheader" class="table-mobile-header">{{ __('Name') }}</div> - <div class="table-mobile-content">{{ testCase.name }}</div> - </div> + <div class="table-section section-20 section-wrap"> + <div role="rowheader" class="table-mobile-header">{{ __('Name') }}</div> + <div class="table-mobile-content">{{ testCase.name }}</div> + </div> - <div class="table-section section-10 section-wrap"> - <div role="rowheader" class="table-mobile-header">{{ __('Status') }}</div> - <div class="table-mobile-content text-center"> - <div - class="add-border ci-status-icon d-flex align-items-center justify-content-end justify-content-md-center" - :class="`ci-status-icon-${testCase.status}`" - > - <icon :size="24" :name="testCase.icon" /> + <div class="table-section section-10 section-wrap"> + <div role="rowheader" class="table-mobile-header">{{ __('Status') }}</div> + <div class="table-mobile-content text-center"> + <div + class="add-border ci-status-icon d-flex align-items-center justify-content-end justify-content-md-center" + :class="`ci-status-icon-${testCase.status}`" + > + <icon :size="24" :name="testCase.icon" /> + </div> </div> </div> - </div> - <div class="table-section flex-grow-1"> - <div role="rowheader" class="table-mobile-header">{{ __('Trace'), }}</div> - <div class="table-mobile-content"> - <pre - v-if="testCase.system_output" - class="build-trace build-trace-rounded text-left" - ><code class="bash p-0">{{testCase.system_output}}</code></pre> + <div class="table-section flex-grow-1"> + <div role="rowheader" class="table-mobile-header">{{ __('Trace'), }}</div> + <div class="table-mobile-content"> + <pre + v-if="testCase.system_output" + class="build-trace build-trace-rounded text-left" + ><code class="bash p-0">{{testCase.system_output}}</code></pre> + </div> </div> - </div> - <div class="table-section section-10 section-wrap"> - <div role="rowheader" class="table-mobile-header"> - {{ __('Duration') }} - </div> - <div class="table-mobile-content text-right"> - {{ testCase.formattedTime }} + <div class="table-section section-10 section-wrap"> + <div role="rowheader" class="table-mobile-header"> + {{ __('Duration') }} + </div> + <div class="table-mobile-content text-right pr-sm-1"> + {{ testCase.formattedTime }} + </div> </div> </div> - </div> + </smart-virtual-list> </div> <div v-else> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue index 1bac7ce9ac5..2fa3fa41eed 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlLink, GlProgressBar } from '@gitlab/ui'; +import { GlButton, GlProgressBar } from '@gitlab/ui'; import { __ } from '~/locale'; import { formatTime, secondsToMilliseconds } from '~/lib/utils/datetime_utility'; import Icon from '~/vue_shared/components/icon.vue'; @@ -8,7 +8,6 @@ export default { name: 'TestSummary', components: { GlButton, - GlLink, GlProgressBar, Icon, }, diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue index 96177512e35..6effd6e949d 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue @@ -2,9 +2,13 @@ import { mapGetters } from 'vuex'; import { s__ } from '~/locale'; import store from '~/pipelines/stores/test_reports'; +import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; export default { name: 'TestsSummaryTable', + components: { + SmartVirtualList, + }, store, props: { heading: { @@ -24,6 +28,8 @@ export default { this.$emit('row-click', suite); }, }, + maxShownRows: 20, + typicalRowHeight: 55, }; </script> @@ -35,7 +41,7 @@ export default { </div> </div> - <div v-if="hasSuites" class="test-reports-table js-test-suites-table"> + <div v-if="hasSuites" class="test-reports-table append-bottom-default js-test-suites-table"> <div role="row" class="gl-responsive-table-row table-row-header font-weight-bold"> <div role="rowheader" class="table-section section-25 pl-3"> {{ __('Suite') }} @@ -60,66 +66,72 @@ export default { </div> </div> - <div - v-for="(testSuite, index) in getTestSuites" - :key="index" - role="row" - class="gl-responsive-table-row gl-responsive-table-row-clickable test-reports-summary-row rounded cursor-pointer js-suite-row" - @click="tableRowClick(testSuite)" + <smart-virtual-list + :length="getTestSuites.length" + :remain="$options.maxShownRows" + :size="$options.typicalRowHeight" > - <div class="table-section section-25"> - <div role="rowheader" class="table-mobile-header font-weight-bold"> - {{ __('Suite') }} - </div> - <div class="table-mobile-content underline cgray pl-3"> - {{ testSuite.name }} + <div + v-for="(testSuite, index) in getTestSuites" + :key="index" + role="row" + class="gl-responsive-table-row gl-responsive-table-row-clickable test-reports-summary-row rounded cursor-pointer js-suite-row" + @click="tableRowClick(testSuite)" + > + <div class="table-section section-25"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Suite') }} + </div> + <div class="table-mobile-content underline cgray pl-3"> + {{ testSuite.name }} + </div> </div> - </div> - <div class="table-section section-25"> - <div role="rowheader" class="table-mobile-header font-weight-bold"> - {{ __('Duration') }} + <div class="table-section section-25"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Duration') }} + </div> + <div class="table-mobile-content text-md-left"> + {{ testSuite.formattedTime }} + </div> </div> - <div class="table-mobile-content text-md-left"> - {{ testSuite.formattedTime }} - </div> - </div> - <div class="table-section section-10 text-center"> - <div role="rowheader" class="table-mobile-header font-weight-bold"> - {{ __('Failed') }} + <div class="table-section section-10 text-center"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Failed') }} + </div> + <div class="table-mobile-content">{{ testSuite.failed_count }}</div> </div> - <div class="table-mobile-content">{{ testSuite.failed_count }}</div> - </div> - <div class="table-section section-10 text-center"> - <div role="rowheader" class="table-mobile-header font-weight-bold"> - {{ __('Errors') }} + <div class="table-section section-10 text-center"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Errors') }} + </div> + <div class="table-mobile-content">{{ testSuite.error_count }}</div> </div> - <div class="table-mobile-content">{{ testSuite.error_count }}</div> - </div> - <div class="table-section section-10 text-center"> - <div role="rowheader" class="table-mobile-header font-weight-bold"> - {{ __('Skipped') }} + <div class="table-section section-10 text-center"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Skipped') }} + </div> + <div class="table-mobile-content">{{ testSuite.skipped_count }}</div> </div> - <div class="table-mobile-content">{{ testSuite.skipped_count }}</div> - </div> - <div class="table-section section-10 text-center"> - <div role="rowheader" class="table-mobile-header font-weight-bold"> - {{ __('Passed') }} + <div class="table-section section-10 text-center"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Passed') }} + </div> + <div class="table-mobile-content">{{ testSuite.success_count }}</div> </div> - <div class="table-mobile-content">{{ testSuite.success_count }}</div> - </div> - <div class="table-section section-10 text-right pr-md-3"> - <div role="rowheader" class="table-mobile-header font-weight-bold"> - {{ __('Total') }} + <div class="table-section section-10 text-right pr-md-3"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Total') }} + </div> + <div class="table-mobile-content">{{ testSuite.total_count }}</div> </div> - <div class="table-mobile-content">{{ testSuite.total_count }}</div> </div> - </div> + </smart-virtual-list> </div> <div v-else> diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index d8dbc3c2454..c874c4c6fdd 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import Flash from '~/flash'; import Translate from '~/vue_shared/translate'; import { __ } from '~/locale'; +import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; import pipelineGraph from './components/graph/graph_component.vue'; import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import PipelinesMediator from './pipeline_details_mediator'; @@ -62,9 +63,11 @@ export default () => { }, created() { eventHub.$on('headerPostAction', this.postAction); + eventHub.$on('headerDeleteAction', this.deleteAction); }, beforeDestroy() { eventHub.$off('headerPostAction', this.postAction); + eventHub.$off('headerDeleteAction', this.deleteAction); }, methods: { postAction(action) { @@ -73,6 +76,13 @@ export default () => { .then(() => this.mediator.refreshPipeline()) .catch(() => Flash(__('An error occurred while making the request.'))); }, + deleteAction(action) { + this.mediator.stopPipelinePoll(); + this.mediator.service + .deleteAction(action.path) + .then(({ request }) => redirectTo(setUrlFragment(request.responseURL, 'delete_success'))) + .catch(() => Flash(__('An error occurred while deleting the pipeline.'))); + }, }, render(createElement) { return createElement('pipeline-header', { diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js index bf021a0b447..f3387f00fc1 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js +++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js @@ -35,7 +35,7 @@ export default class pipelinesMediator { if (!Visibility.hidden()) { this.poll.restart(); } else { - this.poll.stop(); + this.stopPipelinePoll(); } }); } @@ -51,7 +51,7 @@ export default class pipelinesMediator { } refreshPipeline() { - this.poll.stop(); + this.stopPipelinePoll(); return this.service .getPipeline() @@ -64,6 +64,10 @@ export default class pipelinesMediator { ); } + stopPipelinePoll() { + this.poll.stop(); + } + /** * Backend expects paramets in the following format: `expanded[]=id&expanded[]=id` */ diff --git a/app/assets/javascripts/pipelines/services/pipeline_service.js b/app/assets/javascripts/pipelines/services/pipeline_service.js index e44eb9cdfd1..ba2830ec596 100644 --- a/app/assets/javascripts/pipelines/services/pipeline_service.js +++ b/app/assets/javascripts/pipelines/services/pipeline_service.js @@ -10,6 +10,11 @@ export default class PipelineService { } // eslint-disable-next-line class-methods-use-this + deleteAction(endpoint) { + return axios.delete(`${endpoint}.json`); + } + + // eslint-disable-next-line class-methods-use-this postAction(endpoint) { return axios.post(`${endpoint}.json`); } diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js index d6cdd37a2c3..a31034361a8 100644 --- a/app/assets/javascripts/project_find_file.js +++ b/app/assets/javascripts/project_find_file.js @@ -55,26 +55,21 @@ export default class ProjectFindFile { initEvent() { this.inputElement.off('keyup'); - this.inputElement.on( - 'keyup', - (function(_this) { - return function(event) { - const target = $(event.target); - const value = target.val(); - const ref = target.data('oldValue'); - const oldValue = ref != null ? ref : ''; - if (value !== oldValue) { - target.data('oldValue', value); - _this.findFile(); - return _this.element - .find('tr.tree-item') - .eq(0) - .addClass('selected') - .focus(); - } - }; - })(this), - ); + this.inputElement.on('keyup', event => { + const target = $(event.target); + const value = target.val(); + const ref = target.data('oldValue'); + const oldValue = ref != null ? ref : ''; + if (value !== oldValue) { + target.data('oldValue', value); + this.findFile(); + return this.element + .find('tr.tree-item') + .eq(0) + .addClass('selected') + .focus(); + } + }); } findFile() { diff --git a/app/assets/javascripts/projects/project_import_gitlab_project.js b/app/assets/javascripts/projects/project_import_gitlab_project.js index fbef3a0b059..4f222438500 100644 --- a/app/assets/javascripts/projects/project_import_gitlab_project.js +++ b/app/assets/javascripts/projects/project_import_gitlab_project.js @@ -1,19 +1,45 @@ import $ from 'jquery'; +import { convertToTitleCase, humanize, slugify } from '../lib/utils/text_utility'; import { getParameterValues } from '../lib/utils/url_utility'; import projectNew from './project_new'; +const prepareParameters = () => { + const name = getParameterValues('name')[0]; + const path = getParameterValues('path')[0]; + + // If the name param exists but the path doesn't then generate it from the name + if (name && !path) { + return { name, path: slugify(name) }; + } + + // If the path param exists but the name doesn't then generate it from the path + if (path && !name) { + return { name: convertToTitleCase(humanize(path, '-')), path }; + } + + return { name, path }; +}; + export default () => { - const pathParam = getParameterValues('path')[0]; - const nameParam = getParameterValues('name')[0]; - const $projectPath = $('.js-path-name'); + let hasUserDefinedProjectName = false; const $projectName = $('.js-project-name'); - - // get the path url and append it in the input - $projectPath.val(pathParam); + const $projectPath = $('.js-path-name'); + const { name, path } = prepareParameters(); // get the project name from the URL and set it as input value - $projectName.val(nameParam); + $projectName.val(name); + + // get the path url and append it in the input + $projectPath.val(path); // generate slug when project name changes - $projectName.keyup(() => projectNew.onProjectNameChange($projectName, $projectPath)); + $projectName.on('keyup', () => { + projectNew.onProjectNameChange($projectName, $projectPath); + hasUserDefinedProjectName = $projectName.val().trim().length > 0; + }); + + // generate project name from the slug if one isn't set + $projectPath.on('keyup', () => + projectNew.onProjectPathChange($projectName, $projectPath, hasUserDefinedProjectName), + ); }; diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 92c4c05bd87..2aa5f6ec626 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -1,14 +1,45 @@ import $ from 'jquery'; import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils'; -import { slugify } from '../lib/utils/text_utility'; +import { convertToTitleCase, humanize, slugify } from '../lib/utils/text_utility'; import { s__ } from '~/locale'; let hasUserDefinedProjectPath = false; +let hasUserDefinedProjectName = false; + +const onProjectNameChange = ($projectNameInput, $projectPathInput) => { + const slug = slugify($projectNameInput.val()); + $projectPathInput.val(slug); +}; + +const onProjectPathChange = ($projectNameInput, $projectPathInput, hasExistingProjectName) => { + const slug = $projectPathInput.val(); + + if (!hasExistingProjectName) { + $projectNameInput.val(convertToTitleCase(humanize(slug, '[-_]'))); + } +}; + +const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => { + $projectNameInput.off('keyup change').on('keyup change', () => { + onProjectNameChange($projectNameInput, $projectPathInput); + hasUserDefinedProjectName = $projectNameInput.val().trim().length > 0; + hasUserDefinedProjectPath = $projectPathInput.val().trim().length > 0; + }); + + $projectPathInput.off('keyup change').on('keyup change', () => { + onProjectPathChange($projectNameInput, $projectPathInput, hasUserDefinedProjectName); + hasUserDefinedProjectPath = $projectPathInput.val().trim().length > 0; + }); +}; const deriveProjectPathFromUrl = $projectImportUrl => { + const $currentProjectName = $projectImportUrl + .parents('.toggle-import-form') + .find('#project_name'); const $currentProjectPath = $projectImportUrl .parents('.toggle-import-form') .find('#project_path'); + if (hasUserDefinedProjectPath) { return; } @@ -30,14 +61,10 @@ const deriveProjectPathFromUrl = $projectImportUrl => { const pathMatch = /\/([^/]+)$/.exec(importUrl); if (pathMatch) { $currentProjectPath.val(pathMatch[1]); + onProjectPathChange($currentProjectName, $currentProjectPath, false); } }; -const onProjectNameChange = ($projectNameInput, $projectPathInput) => { - const slug = slugify($projectNameInput.val()); - $projectPathInput.val(slug); -}; - const bindEvents = () => { const $newProjectForm = $('#new_project'); const $projectImportUrl = $('#project_import_url'); @@ -202,10 +229,7 @@ const bindEvents = () => { const $activeTabProjectName = $('.tab-pane.active #project_name'); const $activeTabProjectPath = $('.tab-pane.active #project_path'); $activeTabProjectName.focus(); - $activeTabProjectName.keyup(() => { - onProjectNameChange($activeTabProjectName, $activeTabProjectPath); - hasUserDefinedProjectPath = $activeTabProjectPath.val().trim().length > 0; - }); + setProjectNamePathHandlers($activeTabProjectName, $activeTabProjectPath); } $useTemplateBtn.on('change', chooseTemplate); @@ -220,26 +244,24 @@ const bindEvents = () => { $projectPath.val($projectPath.val().trim()); }); - $projectPath.on('keyup', () => { - hasUserDefinedProjectPath = $projectPath.val().trim().length > 0; - }); - $projectImportUrl.keyup(() => deriveProjectPathFromUrl($projectImportUrl)); $('.js-import-git-toggle-button').on('click', () => { const $projectMirror = $('#project_mirror'); $projectMirror.attr('disabled', !$projectMirror.attr('disabled')); + setProjectNamePathHandlers( + $('.tab-pane.active #project_name'), + $('.tab-pane.active #project_path'), + ); }); - $projectName.on('keyup change', () => { - onProjectNameChange($projectName, $projectPath); - hasUserDefinedProjectPath = $projectPath.val().trim().length > 0; - }); + setProjectNamePathHandlers($projectName, $projectPath); }; export default { bindEvents, deriveProjectPathFromUrl, onProjectNameChange, + onProjectPathChange, }; diff --git a/app/assets/javascripts/registry/list/components/collapsible_container.vue b/app/assets/javascripts/registry/list/components/collapsible_container.vue index 86bb2d8092e..9786a1a3f75 100644 --- a/app/assets/javascripts/registry/list/components/collapsible_container.vue +++ b/app/assets/javascripts/registry/list/components/collapsible_container.vue @@ -14,7 +14,7 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import Icon from '~/vue_shared/components/icon.vue'; import TableRegistry from './table_registry.vue'; import { DELETE_REPO_ERROR_MESSAGE } from '../constants'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; export default { name: 'CollapsibeContainerRegisty', @@ -55,6 +55,11 @@ export default { canDeleteRepo() { return this.repo.canDelete && !this.isDeleteDisabled; }, + deleteImageConfirmationMessage() { + return sprintf(__('Image %{imageName} was scheduled for deletion from the registry.'), { + imageName: this.repo.name, + }); + }, }, methods: { ...mapActions(['fetchRepos', 'fetchList', 'deleteItem']), @@ -69,7 +74,7 @@ export default { this.track('confirm_delete'); return this.deleteItem(this.repo) .then(() => { - createFlash(__('This container registry has been scheduled for deletion.'), 'notice'); + createFlash(this.deleteImageConfirmationMessage, 'notice'); this.fetchRepos(); }) .catch(() => createFlash(DELETE_REPO_ERROR_MESSAGE)); diff --git a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue index b2c700b817c..ca495cd2eca 100644 --- a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue +++ b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue @@ -1,26 +1,23 @@ <script> -import { mapState } from 'vuex'; -import { s__, sprintf } from '~/locale'; +import { mapState, mapActions } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import SettingsForm from './settings_form.vue'; export default { - components: {}, + components: { + GlLoadingIcon, + SettingsForm, + }, computed: { ...mapState({ - helpPagePath: 'helpPagePath', + isLoading: 'isLoading', }), - - helpText() { - return sprintf( - s__( - 'PackageRegistry|Read more about the %{helpLinkStart}Container Registry tag retention policies%{helpLinkEnd}', - ), - { - helpLinkStart: `<a href="${this.helpPagePath}" target="_blank">`, - helpLinkEnd: '</a>', - }, - false, - ); - }, + }, + mounted() { + this.fetchSettings(); + }, + methods: { + ...mapActions(['fetchSettings']), }, }; </script> @@ -28,16 +25,19 @@ export default { <template> <div> <p> - {{ s__('PackageRegistry|Tag retention policies are designed to:') }} + {{ s__('ContainerRegistry|Tag expiration policy is designed to:') }} </p> <ul> - <li>{{ s__('PackageRegistry|Keep and protect the images that matter most.') }}</li> + <li>{{ s__('ContainerRegistry|Keep and protect the images that matter most.') }}</li> <li> {{ - s__("PackageRegistry|Automatically remove extra images that aren't designed to be kept.") + s__( + "ContainerRegistry|Automatically remove extra images that aren't designed to be kept.", + ) }} </li> </ul> - <p ref="help-link" v-html="helpText"></p> + <gl-loading-icon v-if="isLoading" ref="loading-icon" size="xl" /> + <settings-form v-else ref="settings-form" /> </div> </template> diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue new file mode 100644 index 00000000000..457bf35daab --- /dev/null +++ b/app/assets/javascripts/registry/settings/components/settings_form.vue @@ -0,0 +1,178 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { GlFormGroup, GlToggle, GlFormSelect, GlFormTextarea, GlButton, GlCard } from '@gitlab/ui'; +import { s__, __, sprintf } from '~/locale'; +import { NAME_REGEX_LENGTH } from '../constants'; +import { mapComputed } from '~/vuex_shared/bindings'; + +export default { + components: { + GlFormGroup, + GlToggle, + GlFormSelect, + GlFormTextarea, + GlButton, + GlCard, + }, + labelsConfig: { + cols: 3, + align: 'right', + }, + computed: { + ...mapState(['formOptions']), + ...mapComputed( + [ + 'enabled', + { key: 'cadence', getter: 'getCadence' }, + { key: 'older_than', getter: 'getOlderThan' }, + { key: 'keep_n', getter: 'getKeepN' }, + 'name_regex', + ], + 'updateSettings', + 'settings', + ), + policyEnabledText() { + return this.enabled ? __('enabled') : __('disabled'); + }, + toggleDescriptionText() { + return sprintf( + s__('ContainerRegistry|Docker tag expiration policy is %{toggleStatus}'), + { + toggleStatus: `<strong>${this.policyEnabledText}</strong>`, + }, + false, + ); + }, + regexHelpText() { + return sprintf( + s__( + 'ContainerRegistry|Wildcards such as %{codeStart}*-stable%{codeEnd} or %{codeStart}production/*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}', + ), + { + codeStart: '<code>', + codeEnd: '</code>', + }, + false, + ); + }, + nameRegexPlaceholder() { + return '.*'; + }, + nameRegexState() { + return this.name_regex ? this.name_regex.length <= NAME_REGEX_LENGTH : null; + }, + formIsInvalid() { + return this.nameRegexState === false; + }, + }, + methods: { + ...mapActions(['resetSettings', 'saveSettings']), + }, +}; +</script> + +<template> + <form ref="form-element" @submit.prevent="saveSettings" @reset.prevent="resetSettings"> + <gl-card> + <template #header> + {{ s__('ContainerRegistry|Tag expiration policy') }} + </template> + <template> + <gl-form-group + id="expiration-policy-toggle-group" + :label-cols="$options.labelsConfig.cols" + :label-align="$options.labelsConfig.align" + label-for="expiration-policy-toggle" + :label="s__('ContainerRegistry|Expiration policy:')" + > + <div class="d-flex align-items-start"> + <gl-toggle id="expiration-policy-toggle" v-model="enabled" /> + <span class="mb-2 ml-1 lh-2" v-html="toggleDescriptionText"></span> + </div> + </gl-form-group> + + <gl-form-group + id="expiration-policy-interval-group" + :label-cols="$options.labelsConfig.cols" + :label-align="$options.labelsConfig.align" + label-for="expiration-policy-interval" + :label="s__('ContainerRegistry|Expiration interval:')" + > + <gl-form-select id="expiration-policy-interval" v-model="older_than" :disabled="!enabled"> + <option v-for="option in formOptions.olderThan" :key="option.key" :value="option.key"> + {{ option.label }} + </option> + </gl-form-select> + </gl-form-group> + + <gl-form-group + id="expiration-policy-schedule-group" + :label-cols="$options.labelsConfig.cols" + :label-align="$options.labelsConfig.align" + label-for="expiration-policy-schedule" + :label="s__('ContainerRegistry|Expiration schedule:')" + > + <gl-form-select id="expiration-policy-schedule" v-model="cadence" :disabled="!enabled"> + <option v-for="option in formOptions.cadence" :key="option.key" :value="option.key"> + {{ option.label }} + </option> + </gl-form-select> + </gl-form-group> + + <gl-form-group + id="expiration-policy-latest-group" + :label-cols="$options.labelsConfig.cols" + :label-align="$options.labelsConfig.align" + label-for="expiration-policy-latest" + :label="s__('ContainerRegistry|Number of tags to retain:')" + > + <gl-form-select id="expiration-policy-latest" v-model="keep_n" :disabled="!enabled"> + <option v-for="option in formOptions.keepN" :key="option.key" :value="option.key"> + {{ option.label }} + </option> + </gl-form-select> + </gl-form-group> + + <gl-form-group + id="expiration-policy-name-matching-group" + :label-cols="$options.labelsConfig.cols" + :label-align="$options.labelsConfig.align" + label-for="expiration-policy-name-matching" + :label="s__('ContainerRegistry|Expire Docker tags that match this regex:')" + :state="nameRegexState" + :invalid-feedback=" + s__('ContainerRegistry|The value of this input should be less than 255 characters') + " + > + <gl-form-textarea + id="expiration-policy-name-matching" + v-model="name_regex" + :placeholder="nameRegexPlaceholder" + :state="nameRegexState" + :disabled="!enabled" + trim + /> + <template #description> + <span ref="regex-description" v-html="regexHelpText"></span> + </template> + </gl-form-group> + </template> + <template #footer> + <div class="d-flex justify-content-end"> + <gl-button ref="cancel-button" type="reset" class="mr-2 d-block">{{ + __('Cancel') + }}</gl-button> + <gl-button + ref="save-button" + type="submit" + :disabled="formIsInvalid" + variant="success" + class="d-block" + > + {{ __('Save expiration policy') }} + </gl-button> + </div> + </template> + </gl-card> + </form> +</template> diff --git a/app/assets/javascripts/registry/settings/constants.js b/app/assets/javascripts/registry/settings/constants.js new file mode 100644 index 00000000000..c0dac466b29 --- /dev/null +++ b/app/assets/javascripts/registry/settings/constants.js @@ -0,0 +1,15 @@ +import { s__ } from '~/locale'; + +export const FETCH_SETTINGS_ERROR_MESSAGE = s__( + 'ContainerRegistry|Something went wrong while fetching the expiration policy.', +); + +export const UPDATE_SETTINGS_ERROR_MESSAGE = s__( + 'ContainerRegistry|Something went wrong while updating the expiration policy.', +); + +export const UPDATE_SETTINGS_SUCCESS_MESSAGE = s__( + 'ContainerRegistry|Expiration policy successfully saved.', +); + +export const NAME_REGEX_LENGTH = 255; diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/registry/settings/registry_settings_bundle.js index 2938178ea86..927b6059884 100644 --- a/app/assets/javascripts/registry/settings/registry_settings_bundle.js +++ b/app/assets/javascripts/registry/settings/registry_settings_bundle.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import Translate from '~/vue_shared/translate'; -import store from './stores/'; +import store from './store/'; import RegistrySettingsApp from './components/registry_settings_app.vue'; Vue.use(Translate); diff --git a/app/assets/javascripts/registry/settings/store/actions.js b/app/assets/javascripts/registry/settings/store/actions.js new file mode 100644 index 00000000000..5e46d564121 --- /dev/null +++ b/app/assets/javascripts/registry/settings/store/actions.js @@ -0,0 +1,42 @@ +import Api from '~/api'; +import createFlash from '~/flash'; +import { + FETCH_SETTINGS_ERROR_MESSAGE, + UPDATE_SETTINGS_ERROR_MESSAGE, + UPDATE_SETTINGS_SUCCESS_MESSAGE, +} from '../constants'; +import * as types from './mutation_types'; + +export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data); +export const updateSettings = ({ commit }, data) => commit(types.UPDATE_SETTINGS, data); +export const toggleLoading = ({ commit }) => commit(types.TOGGLE_LOADING); +export const receiveSettingsSuccess = ({ commit }, data = {}) => commit(types.SET_SETTINGS, data); +export const receiveSettingsError = () => createFlash(FETCH_SETTINGS_ERROR_MESSAGE); +export const updateSettingsError = () => createFlash(UPDATE_SETTINGS_ERROR_MESSAGE); +export const resetSettings = ({ commit }) => commit(types.RESET_SETTINGS); + +export const fetchSettings = ({ dispatch, state }) => { + dispatch('toggleLoading'); + return Api.project(state.projectId) + .then(({ data: { container_expiration_policy } }) => + dispatch('receiveSettingsSuccess', container_expiration_policy), + ) + .catch(() => dispatch('receiveSettingsError')) + .finally(() => dispatch('toggleLoading')); +}; + +export const saveSettings = ({ dispatch, state }) => { + dispatch('toggleLoading'); + return Api.updateProject(state.projectId, { + container_expiration_policy_attributes: state.settings, + }) + .then(({ data: { container_expiration_policy } }) => { + dispatch('receiveSettingsSuccess', container_expiration_policy); + createFlash(UPDATE_SETTINGS_SUCCESS_MESSAGE, 'success'); + }) + .catch(() => dispatch('updateSettingsError')) + .finally(() => dispatch('toggleLoading')); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/registry/settings/store/getters.js b/app/assets/javascripts/registry/settings/store/getters.js new file mode 100644 index 00000000000..fc32a9f08e4 --- /dev/null +++ b/app/assets/javascripts/registry/settings/store/getters.js @@ -0,0 +1,8 @@ +import { findDefaultOption } from '../utils'; + +export const getCadence = state => + state.settings.cadence || findDefaultOption(state.formOptions.cadence); +export const getKeepN = state => + state.settings.keep_n || findDefaultOption(state.formOptions.keepN); +export const getOlderThan = state => + state.settings.older_than || findDefaultOption(state.formOptions.olderThan); diff --git a/app/assets/javascripts/registry/settings/stores/index.js b/app/assets/javascripts/registry/settings/store/index.js index 91a35aac149..c2500454d8e 100644 --- a/app/assets/javascripts/registry/settings/stores/index.js +++ b/app/assets/javascripts/registry/settings/store/index.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import * as actions from './actions'; import mutations from './mutations'; +import * as getters from './getters'; import state from './state'; Vue.use(Vuex); @@ -11,6 +12,7 @@ export const createStore = () => state, actions, mutations, + getters, }); export default createStore(); diff --git a/app/assets/javascripts/registry/settings/store/mutation_types.js b/app/assets/javascripts/registry/settings/store/mutation_types.js new file mode 100644 index 00000000000..db499ffa761 --- /dev/null +++ b/app/assets/javascripts/registry/settings/store/mutation_types.js @@ -0,0 +1,5 @@ +export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; +export const UPDATE_SETTINGS = 'UPDATE_SETTINGS'; +export const TOGGLE_LOADING = 'TOGGLE_LOADING'; +export const SET_SETTINGS = 'SET_SETTINGS'; +export const RESET_SETTINGS = 'RESET_SETTINGS'; diff --git a/app/assets/javascripts/registry/settings/store/mutations.js b/app/assets/javascripts/registry/settings/store/mutations.js new file mode 100644 index 00000000000..25a67cc6973 --- /dev/null +++ b/app/assets/javascripts/registry/settings/store/mutations.js @@ -0,0 +1,25 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_INITIAL_STATE](state, initialState) { + state.projectId = initialState.projectId; + state.formOptions = { + cadence: JSON.parse(initialState.cadenceOptions), + keepN: JSON.parse(initialState.keepNOptions), + olderThan: JSON.parse(initialState.olderThanOptions), + }; + }, + [types.UPDATE_SETTINGS](state, settings) { + state.settings = { ...state.settings, ...settings }; + }, + [types.SET_SETTINGS](state, settings) { + state.settings = settings; + state.original = Object.freeze(settings); + }, + [types.RESET_SETTINGS](state) { + state.settings = { ...state.original }; + }, + [types.TOGGLE_LOADING](state) { + state.isLoading = !state.isLoading; + }, +}; diff --git a/app/assets/javascripts/registry/settings/store/state.js b/app/assets/javascripts/registry/settings/store/state.js new file mode 100644 index 00000000000..50c882e1839 --- /dev/null +++ b/app/assets/javascripts/registry/settings/store/state.js @@ -0,0 +1,30 @@ +export default () => ({ + /* + * Project Id used to build the API call + */ + projectId: '', + /* + * Boolean to determine if the UI is loading data from the API + */ + isLoading: false, + /* + * This contains the data shown and manipulated in the UI + * Has the following structure: + * { + * enabled: Boolean + * cadence: String, + * older_than: String, + * keep_n: String, + * name_regex: String + * } + */ + settings: {}, + /* + * Same structure as settings, above but Frozen object and used only in case the user clicks 'cancel' + */ + original: {}, + /* + * Contains the options used to populate the form selects + */ + formOptions: {}, +}); diff --git a/app/assets/javascripts/registry/settings/stores/actions.js b/app/assets/javascripts/registry/settings/stores/actions.js deleted file mode 100644 index f2c469d4edb..00000000000 --- a/app/assets/javascripts/registry/settings/stores/actions.js +++ /dev/null @@ -1,6 +0,0 @@ -import * as types from './mutation_types'; - -export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data); - -// to avoid eslint error until more actions are added to the store -export default () => {}; diff --git a/app/assets/javascripts/registry/settings/stores/mutation_types.js b/app/assets/javascripts/registry/settings/stores/mutation_types.js deleted file mode 100644 index 8a0f519eabd..00000000000 --- a/app/assets/javascripts/registry/settings/stores/mutation_types.js +++ /dev/null @@ -1,4 +0,0 @@ -export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; - -// to avoid eslint error until more actions are added to the store -export default () => {}; diff --git a/app/assets/javascripts/registry/settings/stores/mutations.js b/app/assets/javascripts/registry/settings/stores/mutations.js deleted file mode 100644 index 4f32e11ed52..00000000000 --- a/app/assets/javascripts/registry/settings/stores/mutations.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as types from './mutation_types'; - -export default { - [types.SET_INITIAL_STATE](state, initialState) { - state.helpPagePath = initialState.helpPagePath; - state.registrySettingsEndpoint = initialState.registrySettingsEndpoint; - }, -}; diff --git a/app/assets/javascripts/registry/settings/stores/state.js b/app/assets/javascripts/registry/settings/stores/state.js deleted file mode 100644 index 4c0439458b6..00000000000 --- a/app/assets/javascripts/registry/settings/stores/state.js +++ /dev/null @@ -1,10 +0,0 @@ -export default () => ({ - /* - * Help page path to generate the link - */ - helpPagePath: '', - /* - * Settings endpoint to call to fetch and update the settings - */ - registrySettingsEndpoint: '', -}); diff --git a/app/assets/javascripts/registry/settings/utils.js b/app/assets/javascripts/registry/settings/utils.js new file mode 100644 index 00000000000..75af401e96d --- /dev/null +++ b/app/assets/javascripts/registry/settings/utils.js @@ -0,0 +1,6 @@ +export const findDefaultOption = options => { + const item = options.find(o => o.default); + return item ? item.key : null; +}; + +export default () => {}; diff --git a/app/assets/javascripts/releases/list/components/app.vue b/app/assets/javascripts/releases/list/components/app.vue index a414b3ccd4e..eb63e709ebd 100644 --- a/app/assets/javascripts/releases/list/components/app.vue +++ b/app/assets/javascripts/releases/list/components/app.vue @@ -66,7 +66,7 @@ export default { :svg-path="illustrationPath" :description=" __( - 'Releases mark specific points in a project\'s development history, communicate information about the type of change, and deliver on prepared, often compiled, versions of the software to be reused elsewhere. Currently, releases can only be created through the API.', + 'Releases are based on Git tags and mark specific points in a project\'s development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software.', ) " :primary-button-link="documentationLink" diff --git a/app/assets/javascripts/releases/list/components/release_block.vue b/app/assets/javascripts/releases/list/components/release_block.vue index 4d8d8682401..d924b5795f0 100644 --- a/app/assets/javascripts/releases/list/components/release_block.vue +++ b/app/assets/javascripts/releases/list/components/release_block.vue @@ -1,35 +1,27 @@ <script> -/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import _ from 'underscore'; -import { GlTooltipDirective, GlLink, GlBadge, GlButton } 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 { __, n__, sprintf } from '~/locale'; import { slugify } from '~/lib/utils/text_utility'; import { getLocationHash } from '~/lib/utils/url_utility'; import { scrollToElement } from '~/lib/utils/common_utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import ReleaseBlockFooter from './release_block_footer.vue'; import EvidenceBlock from './evidence_block.vue'; +import ReleaseBlockAssets from './release_block_assets.vue'; +import ReleaseBlockFooter from './release_block_footer.vue'; +import ReleaseBlockHeader from './release_block_header.vue'; +import ReleaseBlockMetadata from './release_block_metadata.vue'; import ReleaseBlockMilestoneInfo from './release_block_milestone_info.vue'; export default { name: 'ReleaseBlock', components: { EvidenceBlock, - GlLink, - GlBadge, - GlButton, - Icon, - UserAvatarLink, + ReleaseBlockAssets, ReleaseBlockFooter, + ReleaseBlockHeader, + ReleaseBlockMetadata, ReleaseBlockMilestoneInfo, }, - directives: { - GlTooltip: GlTooltipDirective, - }, - mixins: [timeagoMixin, glFeatureFlagsMixin()], + mixins: [glFeatureFlagsMixin()], props: { release: { type: Object, @@ -46,45 +38,14 @@ export default { id() { return slugify(this.release.tag_name); }, - releasedTimeAgo() { - return sprintf(__('released %{time}'), { - time: this.timeFormatted(this.release.released_at), - }); - }, - userImageAltDescription() { - return this.author && this.author.username - ? sprintf(__("%{username}'s avatar"), { username: this.author.username }) - : null; - }, - commit() { - return this.release.commit || {}; - }, - commitUrl() { - return this.release.commit_path; - }, - tagUrl() { - return this.release.tag_path; - }, assets() { return this.release.assets || {}; }, - author() { - return this.release.author || {}; - }, - hasAuthor() { - return !_.isEmpty(this.author); - }, hasEvidence() { return Boolean(this.release.evidence_sha); }, - shouldRenderMilestones() { - return !_.isEmpty(this.release.milestones); - }, - labelText() { - return n__('Milestone', 'Milestones', this.release.milestones.length); - }, - shouldShowEditButton() { - return Boolean(this.release._links && this.release._links.edit_url); + milestones() { + return this.release.milestones || []; }, shouldShowEvidence() { return this.glFeatures.releaseEvidenceCollection; @@ -92,6 +53,11 @@ export default { shouldShowFooter() { return this.glFeatures.releaseIssueSummary; }, + shouldRenderAssets() { + return Boolean( + this.assets.links.length || (this.assets.sources && this.assets.sources.length), + ); + }, shouldRenderReleaseMetaData() { return !this.glFeatures.releaseIssueSummary; }, @@ -114,127 +80,15 @@ export default { </script> <template> <div :id="id" :class="{ 'bg-line-target-blue': isHighlighted }" class="card release-block"> - <div class="card-header d-flex align-items-center bg-white pr-0"> - <h2 class="card-title my-2 mr-auto gl-font-size-20"> - {{ release.name }} - <gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{ - __('Upcoming Release') - }}</gl-badge> - </h2> - <gl-link - v-if="shouldShowEditButton" - v-gl-tooltip - class="btn btn-default append-right-10 js-edit-button ml-2" - :title="__('Edit this release')" - :href="release._links.edit_url" - > - <icon name="pencil" /> - </gl-link> - </div> + <release-block-header :release="release" /> <div class="card-body"> <div v-if="shouldRenderMilestoneInfo"> - <release-block-milestone-info :milestones="release.milestones" /> + <release-block-milestone-info :milestones="milestones" /> <hr class="mb-3 mt-0" /> </div> - <div v-if="shouldRenderReleaseMetaData" class="card-subtitle d-flex flex-wrap text-secondary"> - <div class="append-right-8"> - <icon name="commit" class="align-middle" /> - <gl-link v-if="commitUrl" v-gl-tooltip.bottom :title="commit.title" :href="commitUrl"> - {{ commit.short_id }} - </gl-link> - <span v-else v-gl-tooltip.bottom :title="commit.title">{{ commit.short_id }}</span> - </div> - - <div class="append-right-8"> - <icon name="tag" class="align-middle" /> - <gl-link v-if="tagUrl" v-gl-tooltip.bottom :title="__('Tag')" :href="tagUrl"> - {{ release.tag_name }} - </gl-link> - <span v-else v-gl-tooltip.bottom :title="__('Tag')">{{ release.tag_name }}</span> - </div> - - <template v-if="shouldRenderMilestones"> - <div class="js-milestone-list-label"> - <icon name="flag" class="align-middle" /> - <span class="js-label-text">{{ labelText }}</span> - </div> - - <template v-for="(milestone, index) in release.milestones"> - <gl-link - :key="milestone.id" - v-gl-tooltip - :title="milestone.description" - :href="milestone.web_url" - class="append-right-4 prepend-left-4 js-milestone-link" - > - {{ milestone.title }} - </gl-link> - <template v-if="index !== release.milestones.length - 1"> - • - </template> - </template> - </template> - - <div class="append-right-4"> - • - <span v-gl-tooltip.bottom :title="tooltipTitle(release.released_at)"> - {{ releasedTimeAgo }} - </span> - </div> - - <div v-if="hasAuthor" class="d-flex"> - by - <user-avatar-link - class="prepend-left-4" - :link-href="author.web_url" - :img-src="author.avatar_url" - :img-alt="userImageAltDescription" - :tooltip-text="author.username" - /> - </div> - </div> - - <div - v-if="assets.links.length || (assets.sources && assets.sources.length)" - class="card-text prepend-top-default" - > - <b> - {{ __('Assets') }} - <span class="js-assets-count badge badge-pill">{{ assets.count }}</span> - </b> - - <ul v-if="assets.links.length" class="pl-0 mb-0 prepend-top-8 list-unstyled js-assets-list"> - <li v-for="link in assets.links" :key="link.name" class="append-bottom-8"> - <gl-link v-gl-tooltip.bottom :title="__('Download asset')" :href="link.url"> - <icon name="package" class="align-middle append-right-4 align-text-bottom" /> - {{ link.name }} - <span v-if="link.external">{{ __('(external source)') }}</span> - </gl-link> - </li> - </ul> - - <div v-if="assets.sources && assets.sources.length" class="dropdown"> - <button - type="button" - class="btn btn-link" - data-toggle="dropdown" - aria-haspopup="true" - aria-expanded="false" - > - <icon name="doc-code" class="align-top append-right-4" /> - {{ __('Source code') }} - <icon name="arrow-down" /> - </button> - - <div class="js-sources-dropdown dropdown-menu"> - <li v-for="asset in assets.sources" :key="asset.url"> - <gl-link :href="asset.url">{{ __('Download') }} {{ asset.format }}</gl-link> - </li> - </div> - </div> - </div> - + <release-block-metadata v-if="shouldRenderReleaseMetaData" :release="release" /> + <release-block-assets v-if="shouldRenderAssets" :assets="assets" /> <evidence-block v-if="hasEvidence && shouldShowEvidence" :release="release" /> <div class="card-text prepend-top-default"> diff --git a/app/assets/javascripts/releases/list/components/release_block_assets.vue b/app/assets/javascripts/releases/list/components/release_block_assets.vue new file mode 100644 index 00000000000..e840bc90d68 --- /dev/null +++ b/app/assets/javascripts/releases/list/components/release_block_assets.vue @@ -0,0 +1,65 @@ +<script> +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + name: 'ReleaseBlockAssets', + components: { + GlLink, + Icon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + assets: { + type: Object, + required: true, + }, + }, + computed: { + hasAssets() { + return Boolean(this.assets.count); + }, + }, +}; +</script> + +<template> + <div class="card-text prepend-top-default"> + <b> + {{ __('Assets') }} + <span class="js-assets-count badge badge-pill">{{ assets.count }}</span> + </b> + + <ul v-if="assets.links.length" class="pl-0 mb-0 prepend-top-8 list-unstyled js-assets-list"> + <li v-for="link in assets.links" :key="link.name" class="append-bottom-8"> + <gl-link v-gl-tooltip.bottom :title="__('Download asset')" :href="link.url"> + <icon name="package" class="align-middle append-right-4 align-text-bottom" /> + {{ link.name }} + <span v-if="link.external">{{ __('(external source)') }}</span> + </gl-link> + </li> + </ul> + + <div v-if="hasAssets" class="dropdown"> + <button + type="button" + class="btn btn-link" + data-toggle="dropdown" + aria-haspopup="true" + aria-expanded="false" + > + <icon name="doc-code" class="align-top append-right-4" /> + {{ __('Source code') }} + <icon name="arrow-down" /> + </button> + + <div class="js-sources-dropdown dropdown-menu"> + <li v-for="asset in assets.sources" :key="asset.url"> + <gl-link :href="asset.url">{{ __('Download') }} {{ asset.format }}</gl-link> + </li> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/releases/list/components/release_block_author.vue b/app/assets/javascripts/releases/list/components/release_block_author.vue new file mode 100644 index 00000000000..ff6b00d8221 --- /dev/null +++ b/app/assets/javascripts/releases/list/components/release_block_author.vue @@ -0,0 +1,42 @@ +<script> +import { __, sprintf } from '~/locale'; +import { GlSprintf } from '@gitlab/ui'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; + +export default { + name: 'ReleaseBlockAuthor', + components: { + GlSprintf, + UserAvatarLink, + }, + props: { + author: { + type: Object, + required: true, + }, + }, + computed: { + userImageAltDescription() { + return this.author && this.author.username + ? sprintf(__("%{username}'s avatar"), { username: this.author.username }) + : null; + }, + }, +}; +</script> + +<template> + <div class="d-flex"> + <gl-sprintf message="by %{user}"> + <template #user> + <user-avatar-link + class="prepend-left-4" + :link-href="author.web_url" + :img-src="author.avatar_url" + :img-alt="userImageAltDescription" + :tooltip-text="author.username" + /> + </template> + </gl-sprintf> + </div> +</template> diff --git a/app/assets/javascripts/releases/list/components/release_block_header.vue b/app/assets/javascripts/releases/list/components/release_block_header.vue new file mode 100644 index 00000000000..9c5dcf2a709 --- /dev/null +++ b/app/assets/javascripts/releases/list/components/release_block_header.vue @@ -0,0 +1,47 @@ +<script> +import { GlTooltipDirective, GlLink, GlBadge } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + name: 'ReleaseBlockHeader', + components: { + GlLink, + GlBadge, + Icon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + release: { + type: Object, + required: true, + }, + }, + computed: { + shouldShowEditButton() { + return Boolean(this.release._links && this.release._links.edit_url); + }, + }, +}; +</script> + +<template> + <div class="card-header d-flex align-items-center bg-white pr-0"> + <h2 class="card-title my-2 mr-auto gl-font-size-20"> + {{ release.name }} + <gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{ + __('Upcoming Release') + }}</gl-badge> + </h2> + <gl-link + v-if="shouldShowEditButton" + v-gl-tooltip + class="btn btn-default append-right-10 js-edit-button ml-2" + :title="__('Edit this release')" + :href="release._links.edit_url" + > + <icon name="pencil" /> + </gl-link> + </div> +</template> diff --git a/app/assets/javascripts/releases/list/components/release_block_metadata.vue b/app/assets/javascripts/releases/list/components/release_block_metadata.vue new file mode 100644 index 00000000000..f0aad594062 --- /dev/null +++ b/app/assets/javascripts/releases/list/components/release_block_metadata.vue @@ -0,0 +1,84 @@ +<script> +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import ReleaseBlockAuthor from './release_block_author.vue'; +import ReleaseBlockMilestones from './release_block_milestones.vue'; + +export default { + name: 'ReleaseBlockMetadata', + components: { + Icon, + GlLink, + ReleaseBlockAuthor, + ReleaseBlockMilestones, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [timeagoMixin], + props: { + release: { + type: Object, + required: true, + }, + }, + computed: { + author() { + return this.release.author; + }, + commit() { + return this.release.commit || {}; + }, + commitUrl() { + return this.release.commit_path; + }, + hasAuthor() { + return Boolean(this.author); + }, + releasedTimeAgo() { + return sprintf(__('released %{time}'), { + time: this.timeFormatted(this.release.released_at), + }); + }, + shouldRenderMilestones() { + return Boolean(this.release.milestones?.length); + }, + tagUrl() { + return this.release.tag_path; + }, + }, +}; +</script> + +<template> + <div class="card-subtitle d-flex flex-wrap text-secondary"> + <div class="append-right-8"> + <icon name="commit" class="align-middle" /> + <gl-link v-if="commitUrl" v-gl-tooltip.bottom :title="commit.title" :href="commitUrl"> + {{ commit.short_id }} + </gl-link> + <span v-else v-gl-tooltip.bottom :title="commit.title">{{ commit.short_id }}</span> + </div> + + <div class="append-right-8"> + <icon name="tag" class="align-middle" /> + <gl-link v-if="tagUrl" v-gl-tooltip.bottom :title="__('Tag')" :href="tagUrl"> + {{ release.tag_name }} + </gl-link> + <span v-else v-gl-tooltip.bottom :title="__('Tag')">{{ release.tag_name }}</span> + </div> + + <release-block-milestones v-if="shouldRenderMilestones" :milestones="release.milestones" /> + + <div class="append-right-4"> + • + <span v-gl-tooltip.bottom :title="tooltipTitle(release.released_at)"> + {{ releasedTimeAgo }} + </span> + </div> + + <release-block-author v-if="hasAuthor" :author="author" /> + </div> +</template> diff --git a/app/assets/javascripts/releases/list/components/release_block_milestones.vue b/app/assets/javascripts/releases/list/components/release_block_milestones.vue new file mode 100644 index 00000000000..a3dff75b828 --- /dev/null +++ b/app/assets/javascripts/releases/list/components/release_block_milestones.vue @@ -0,0 +1,51 @@ +<script> +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { n__ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + name: 'ReleaseBlockMilestones', + components: { + GlLink, + Icon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + milestones: { + type: Array, + required: true, + }, + }, + computed: { + labelText() { + return n__('Milestone', 'Milestones', this.milestones.length); + }, + }, +}; +</script> + +<template> + <div> + <div class="js-milestone-list-label"> + <icon name="flag" class="align-middle" /> + <span class="js-label-text">{{ labelText }}</span> + </div> + + <template v-for="(milestone, index) in milestones"> + <gl-link + :key="milestone.id" + v-gl-tooltip + :title="milestone.description" + :href="milestone.web_url" + class="mx-1 js-milestone-link" + > + {{ milestone.title }} + </gl-link> + <template v-if="index !== milestones.length - 1"> + • + </template> + </template> + </div> +</template> diff --git a/app/assets/javascripts/reports/components/modal.vue b/app/assets/javascripts/reports/components/modal.vue index 6019af2dfe0..40ce200befb 100644 --- a/app/assets/javascripts/reports/components/modal.vue +++ b/app/assets/javascripts/reports/components/modal.vue @@ -1,14 +1,12 @@ <script> // import { sprintf, __ } from '~/locale'; import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; import CodeBlock from '~/vue_shared/components/code_block.vue'; import { fieldTypes } from '../constants'; export default { components: { Modal: DeprecatedModal2, - LoadingButton, CodeBlock, }, props: { diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue index 45c890769a0..20b0c52dbda 100644 --- a/app/assets/javascripts/reports/components/report_section.vue +++ b/app/assets/javascripts/reports/components/report_section.vue @@ -165,21 +165,23 @@ export default { <template> <section class="media-section"> <div class="media"> - <status-icon :status="statusIconName" :size="24" /> - <div class="media-body d-flex flex-align-self-center"> - <span class="js-code-text code-text"> - {{ headerText }} - <slot :name="slotName"></slot> - - <popover v-if="hasPopover" :options="popoverOptions" class="prepend-left-5" /> - </span> + <status-icon :status="statusIconName" :size="24" class="align-self-center" /> + <div class="media-body d-flex flex-align-self-center align-items-center"> + <div class="js-code-text code-text"> + <div> + {{ headerText }} + <slot :name="slotName"></slot> + <popover v-if="hasPopover" :options="popoverOptions" class="prepend-left-5" /> + </div> + <slot name="subHeading"></slot> + </div> <slot name="actionButtons"></slot> <button v-if="isCollapsible" type="button" - class="js-collapse-btn btn float-right btn-sm align-self-start qa-expand-report-button" + class="js-collapse-btn btn float-right btn-sm align-self-center qa-expand-report-button" @click="toggleCollapsed" > {{ collapseText }} diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 70678b0db37..fe1724acf89 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -90,7 +90,7 @@ export default { <template> <div class="info-well d-none d-sm-flex project-last-commit commit p-3"> - <gl-loading-icon v-if="isLoading" size="md" class="m-auto" /> + <gl-loading-icon v-if="isLoading" size="md" color="dark" class="m-auto" /> <template v-else> <user-avatar-link v-if="commit.author" @@ -104,7 +104,11 @@ export default { </span> <div class="commit-detail flex-list"> <div class="commit-content qa-commit-content"> - <gl-link :href="commit.webUrl" class="commit-row-message item-title"> + <gl-link + :href="commit.webUrl" + :class="{ 'font-italic': !commit.message }" + class="commit-row-message item-title" + > {{ commit.title }} </gl-link> <gl-button diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue index 6b3822151ff..2bc93c3f1c1 100644 --- a/app/assets/javascripts/repository/components/preview/index.vue +++ b/app/assets/javascripts/repository/components/preview/index.vue @@ -44,7 +44,7 @@ export default { </div> </div> <div class="blob-viewer"> - <gl-loading-icon v-if="loading > 0" size="md" class="my-4 mx-auto" /> + <gl-loading-icon v-if="loading > 0" size="md" color="dark" class="my-4 mx-auto" /> <div v-else-if="readme" v-html="readme.html"></div> </div> </article> diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 8f2e9264bca..29a3340b83d 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -34,6 +34,11 @@ export default { type: Boolean, required: true, }, + loadingPath: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -69,7 +74,12 @@ export default { <table :aria-label="tableCaption" class="table tree-table qa-file-tree" aria-live="polite"> <table-header v-once /> <tbody> - <parent-row v-show="showParentRow" :commit-ref="ref" :path="path" /> + <parent-row + v-show="showParentRow" + :commit-ref="ref" + :path="path" + :loading-path="loadingPath" + /> <template v-for="val in entries"> <table-row v-for="entry in val" @@ -84,6 +94,7 @@ export default { :url="entry.webUrl" :submodule-tree-url="entry.treeUrl" :lfs-oid="entry.lfsOid" + :loading-path="loadingPath" /> </template> <template v-if="isLoading"> diff --git a/app/assets/javascripts/repository/components/table/parent_row.vue b/app/assets/javascripts/repository/components/table/parent_row.vue index 3c39f404226..70a188f98cc 100644 --- a/app/assets/javascripts/repository/components/table/parent_row.vue +++ b/app/assets/javascripts/repository/components/table/parent_row.vue @@ -1,5 +1,10 @@ <script> +import { GlLoadingIcon } from '@gitlab/ui'; + export default { + components: { + GlLoadingIcon, + }, props: { commitRef: { type: String, @@ -9,13 +14,21 @@ export default { type: String, required: true, }, + loadingPath: { + type: String, + required: false, + default: null, + }, }, computed: { - parentRoute() { + parentPath() { const splitArray = this.path.split('/'); splitArray.pop(); - return { path: `/tree/${this.commitRef}/${splitArray.join('/')}` }; + return splitArray.join('/'); + }, + parentRoute() { + return { path: `/tree/${this.commitRef}/${this.parentPath}` }; }, }, methods: { @@ -29,7 +42,13 @@ export default { <template> <tr class="tree-item"> <td colspan="3" class="tree-item-file-name" @click.self="clickRow"> - <router-link :to="parentRoute" :aria-label="__('Go to parent')"> + <gl-loading-icon + v-if="parentPath === loadingPath" + size="sm" + inline + class="d-inline-block align-text-bottom" + /> + <router-link v-else :to="parentRoute" :aria-label="__('Go to parent')"> .. </router-link> </td> diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index cf0457a2abf..a8e13241c37 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -1,5 +1,5 @@ <script> -import { GlBadge, GlLink, GlSkeletonLoading, GlTooltipDirective } from '@gitlab/ui'; +import { GlBadge, GlLink, GlSkeletonLoading, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui'; import { visitUrl } from '~/lib/utils/url_utility'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import Icon from '~/vue_shared/components/icon.vue'; @@ -12,6 +12,7 @@ export default { GlBadge, GlLink, GlSkeletonLoading, + GlLoadingIcon, TimeagoTooltip, Icon, }, @@ -76,6 +77,11 @@ export default { required: false, default: null, }, + loadingPath: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -125,7 +131,13 @@ export default { <template> <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> + <gl-loading-icon + v-if="path === loadingPath" + size="sm" + inline + class="d-inline-block align-text-bottom fa-fw" + /> + <i v-else :aria-label="type" role="img" :class="iconName" class="fa fa-fw"></i> <component :is="linkComponent" :to="routerLinkTo" :href="url" class="str-truncated"> {{ fullPath }} </component> diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index 949e653fc8f..92e33b013c3 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -5,6 +5,7 @@ import FileTable from './table/index.vue'; import getRefMixin from '../mixins/get_ref'; import getFiles from '../queries/getFiles.query.graphql'; import getProjectPath from '../queries/getProjectPath.query.graphql'; +import getVueFileListLfsBadge from '../queries/getVueFileListLfsBadge.query.graphql'; import FilePreview from './preview/index.vue'; import { readmeFile } from '../utils/readme'; @@ -20,6 +21,9 @@ export default { projectPath: { query: getProjectPath, }, + vueFileListLfsBadge: { + query: getVueFileListLfsBadge, + }, }, props: { path: { @@ -27,6 +31,11 @@ export default { required: false, default: '/', }, + loadingPath: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -38,6 +47,7 @@ export default { blobs: [], }, isLoadingFiles: false, + vueFileListLfsBadge: false, }; }, computed: { @@ -72,6 +82,7 @@ export default { path: this.path || '/', nextPageCursor: this.nextPageCursor, pageSize: PAGE_SIZE, + vueLfsEnabled: this.vueFileListLfsBadge, }, }) .then(({ data }) => { @@ -109,7 +120,12 @@ export default { <template> <div> - <file-table :path="path" :entries="entries" :is-loading="isLoadingFiles" /> + <file-table + :path="path" + :entries="entries" + :is-loading="isLoadingFiles" + :loading-path="loadingPath" + /> <file-preview v-if="readme" :blob="readme" /> </div> </template> diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index ae6409a0ac9..2ef0c078f13 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -23,6 +23,7 @@ export default function setupVueRepositoryList() { projectPath, projectShortPath, ref, + vueFileListLfsBadge: gon?.features?.vueFileListLfsBadge, commits: [], }, }); diff --git a/app/assets/javascripts/repository/mixins/preload.js b/app/assets/javascripts/repository/mixins/preload.js new file mode 100644 index 00000000000..e68996245a8 --- /dev/null +++ b/app/assets/javascripts/repository/mixins/preload.js @@ -0,0 +1,36 @@ +import getFiles from '../queries/getFiles.query.graphql'; +import getRefMixin from './get_ref'; +import getProjectPath from '../queries/getProjectPath.query.graphql'; + +export default { + mixins: [getRefMixin], + apollo: { + projectPath: { + query: getProjectPath, + }, + }, + data() { + return { projectPath: '', loadingPath: null }; + }, + beforeRouteUpdate(to, from, next) { + this.preload(to.params.pathMatch, next); + }, + methods: { + preload(path, next) { + this.loadingPath = path.replace(/^\//, ''); + + return this.$apollo + .query({ + query: getFiles, + variables: { + projectPath: this.projectPath, + ref: this.ref, + path: this.loadingPath, + nextPageCursor: '', + pageSize: 100, + }, + }) + .then(() => next()); + }, + }, +}; diff --git a/app/assets/javascripts/repository/pages/tree.vue b/app/assets/javascripts/repository/pages/tree.vue index dd4d437f4dd..adc332fa370 100644 --- a/app/assets/javascripts/repository/pages/tree.vue +++ b/app/assets/javascripts/repository/pages/tree.vue @@ -1,11 +1,13 @@ <script> import TreeContent from '../components/tree_content.vue'; import { updateElementsVisibility } from '../utils/dom'; +import preloadMixin from '../mixins/preload'; export default { components: { TreeContent, }, + mixins: [preloadMixin], props: { path: { type: String, @@ -34,5 +36,5 @@ export default { </script> <template> - <tree-content :path="path" /> + <tree-content :path="path" :loading-path="loadingPath" /> </template> diff --git a/app/assets/javascripts/repository/queries/getFiles.query.graphql b/app/assets/javascripts/repository/queries/getFiles.query.graphql index 2aaf5066b4a..01ad72ef752 100644 --- a/app/assets/javascripts/repository/queries/getFiles.query.graphql +++ b/app/assets/javascripts/repository/queries/getFiles.query.graphql @@ -14,6 +14,7 @@ query getFiles( $ref: String! $pageSize: Int! $nextPageCursor: String + $vueLfsEnabled: Boolean = false ) { project(fullPath: $projectPath) { repository { @@ -46,7 +47,7 @@ query getFiles( node { ...TreeEntry webUrl - lfsOid + lfsOid @include(if: $vueLfsEnabled) } } pageInfo { diff --git a/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql b/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql new file mode 100644 index 00000000000..3c3d14881da --- /dev/null +++ b/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql @@ -0,0 +1,3 @@ +query getProjectShortPath { + vueFileListLfsBadge @client +} diff --git a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql index 9be025afe39..c812614e94d 100644 --- a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql +++ b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql @@ -6,6 +6,7 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) { sha title description + message webUrl authoredDate authorName diff --git a/app/assets/javascripts/repository/utils/readme.js b/app/assets/javascripts/repository/utils/readme.js index e43b2bdc33a..5b62271b02e 100644 --- a/app/assets/javascripts/repository/utils/readme.js +++ b/app/assets/javascripts/repository/utils/readme.js @@ -1,21 +1,32 @@ -const MARKDOWN_EXTENSIONS = ['mdown', 'mkd', 'mkdn', 'md', 'markdown']; -const ASCIIDOC_EXTENSIONS = ['adoc', 'ad', 'asciidoc']; -const OTHER_EXTENSIONS = ['textile', 'rdoc', 'org', 'creole', 'wiki', 'mediawiki', 'rst']; -const EXTENSIONS = [...MARKDOWN_EXTENSIONS, ...ASCIIDOC_EXTENSIONS, ...OTHER_EXTENSIONS]; -const PLAIN_FILENAMES = ['readme', 'index']; -const FILE_REGEXP = new RegExp( - `^(${PLAIN_FILENAMES.join('|')})(.(${EXTENSIONS.join('|')}))?$`, - 'i', -); -const PLAIN_FILE_REGEXP = new RegExp(`^(${PLAIN_FILENAMES.join('|')})`, 'i'); -const EXTENSIONS_REGEXP = new RegExp(`.(${EXTENSIONS.join('|')})$`, 'i'); +const FILENAMES = ['index', 'readme']; -// eslint-disable-next-line import/prefer-default-export -export const readmeFile = blobs => { - const readMeFiles = blobs.filter(f => f.name.search(FILE_REGEXP) !== -1); +const MARKUP_EXTENSIONS = [ + 'ad', + 'adoc', + 'asciidoc', + 'creole', + 'markdown', + 'md', + 'mdown', + 'mediawiki', + 'mkd', + 'mkdn', + 'org', + 'rdoc', + 'rst', + 'textile', + 'wiki', +]; - const previewableReadme = readMeFiles.find(f => f.name.search(EXTENSIONS_REGEXP) !== -1); - const plainReadme = readMeFiles.find(f => f.name.search(PLAIN_FILE_REGEXP) !== -1); +const isRichReadme = file => { + const re = new RegExp(`^(${FILENAMES.join('|')})\\.(${MARKUP_EXTENSIONS.join('|')})$`, 'i'); + return re.test(file.name); +}; - return previewableReadme || plainReadme; +const isPlainReadme = file => { + const re = new RegExp(`^(${FILENAMES.join('|')})(\\.txt)?$`, 'i'); + return re.test(file.name); }; + +// eslint-disable-next-line import/prefer-default-export +export const readmeFile = blobs => blobs.find(isRichReadme) || blobs.find(isPlainReadme); diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue new file mode 100644 index 00000000000..2f364eae67f --- /dev/null +++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue @@ -0,0 +1,160 @@ +<script> +import Vue from 'vue'; +import { GlFormGroup, GlButton, GlModal, GlToast, GlToggle } from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; +import { __, s__, sprintf } from '~/locale'; +import { visitUrl, getBaseURL } from '~/lib/utils/url_utility'; + +Vue.use(GlToast); + +export default { + components: { + GlFormGroup, + GlButton, + GlModal, + GlToggle, + }, + formLabels: { + createProject: __('Create Project'), + }, + data() { + return { + modalId: 'delete-self-monitor-modal', + }; + }, + computed: { + ...mapState('selfMonitoring', [ + 'projectEnabled', + 'projectCreated', + 'showAlert', + 'projectPath', + 'loading', + 'alertContent', + ]), + selfMonitorEnabled: { + get() { + return this.projectEnabled; + }, + set(projectEnabled) { + this.setSelfMonitor(projectEnabled); + }, + }, + selfMonitorProjectFullUrl() { + return `${getBaseURL()}/${this.projectPath}`; + }, + selfMonitoringFormText() { + if (this.projectCreated) { + return sprintf( + s__( + 'SelfMonitoring|Enabling this feature creates a %{projectLinkStart}project%{projectLinkEnd} that can be used to monitor the health of your instance.', + ), + { + projectLinkStart: `<a href="${this.selfMonitorProjectFullUrl}">`, + projectLinkEnd: '</a>', + }, + false, + ); + } + + return s__( + 'SelfMonitoring|Enabling this feature creates a project that can be used to monitor the health of your instance.', + ); + }, + }, + watch: { + selfMonitorEnabled() { + this.saveChangesSelfMonitorProject(); + }, + showAlert() { + let toastOptions = { + onComplete: () => { + this.resetAlert(); + }, + }; + + if (this.showAlert) { + if (this.alertContent.actionName && this.alertContent.actionName.length > 0) { + toastOptions = { + ...toastOptions, + action: { + text: this.alertContent.actionText, + onClick: (_, toastObject) => { + this[this.alertContent.actionName](); + toastObject.goAway(0); + }, + }, + }; + } + this.$toast.show(this.alertContent.message, toastOptions); + } + }, + }, + methods: { + ...mapActions('selfMonitoring', [ + 'setSelfMonitor', + 'createProject', + 'deleteProject', + 'resetAlert', + ]), + hideSelfMonitorModal() { + this.$root.$emit('bv::hide::modal', this.modalId); + this.setSelfMonitor(true); + }, + showSelfMonitorModal() { + this.$root.$emit('bv::show::modal', this.modalId); + }, + saveChangesSelfMonitorProject() { + if (this.projectCreated && !this.projectEnabled) { + this.showSelfMonitorModal(); + } else { + this.createProject(); + } + }, + viewSelfMonitorProject() { + visitUrl(this.selfMonitorProjectFullUrl); + }, + }, +}; +</script> +<template> + <section class="settings no-animate js-self-monitoring-settings"> + <div class="settings-header"> + <h4 class="js-section-header"> + {{ s__('SelfMonitoring|Self monitoring') }} + </h4> + <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> + <p class="js-section-sub-header"> + {{ s__('SelfMonitoring|Enable or disable instance self monitoring') }} + </p> + </div> + <div class="settings-content"> + <form name="self-monitoring-form"> + <p v-html="selfMonitoringFormText"></p> + <gl-form-group :label="$options.formLabels.createProject" label-for="self-monitor-toggle"> + <gl-toggle + v-model="selfMonitorEnabled" + :is-loading="loading" + name="self-monitor-toggle" + /> + </gl-form-group> + </form> + </div> + <gl-modal + :title="s__('SelfMonitoring|Disable self monitoring?')" + :modal-id="modalId" + :ok-title="__('Delete project')" + :cancel-title="__('Cancel')" + ok-variant="danger" + @ok="deleteProject" + @cancel="hideSelfMonitorModal" + > + <div> + {{ + s__( + 'SelfMonitoring|Disabling this feature will delete the self monitoring project. Are you sure you want to delete the project?', + ) + }} + </div> + </gl-modal> + </section> +</template> diff --git a/app/assets/javascripts/self_monitor/index.js b/app/assets/javascripts/self_monitor/index.js new file mode 100644 index 00000000000..42c94e11989 --- /dev/null +++ b/app/assets/javascripts/self_monitor/index.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import store from './store'; +import SelfMonitorForm from './components/self_monitor_form.vue'; + +export default () => { + const el = document.querySelector('.js-self-monitoring-settings'); + let selfMonitorProjectCreated; + + if (el) { + selfMonitorProjectCreated = el.dataset.selfMonitoringProjectExists; + // eslint-disable-next-line no-new + new Vue({ + el, + store: store({ + projectEnabled: selfMonitorProjectCreated, + ...el.dataset, + }), + render(createElement) { + return createElement(SelfMonitorForm); + }, + }); + } +}; diff --git a/app/assets/javascripts/self_monitor/store/actions.js b/app/assets/javascripts/self_monitor/store/actions.js new file mode 100644 index 00000000000..f8430a9b136 --- /dev/null +++ b/app/assets/javascripts/self_monitor/store/actions.js @@ -0,0 +1,126 @@ +import { __, s__ } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; +import statusCodes from '~/lib/utils/http_status'; +import { backOff } from '~/lib/utils/common_utils'; +import * as types from './mutation_types'; + +const TWO_MINUTES = 120000; + +function backOffRequest(makeRequestCallback) { + return backOff((next, stop) => { + makeRequestCallback() + .then(resp => { + if (resp.status === statusCodes.ACCEPTED) { + next(); + } else { + stop(resp); + } + }) + .catch(stop); + }, TWO_MINUTES); +} + +export const setSelfMonitor = ({ commit }, enabled) => commit(types.SET_ENABLED, enabled); + +export const createProject = ({ dispatch }) => dispatch('requestCreateProject'); + +export const resetAlert = ({ commit }) => commit(types.SET_SHOW_ALERT, false); + +export const requestCreateProject = ({ dispatch, state, commit }) => { + commit(types.SET_LOADING, true); + axios + .post(state.createProjectEndpoint) + .then(resp => { + if (resp.status === statusCodes.ACCEPTED) { + dispatch('requestCreateProjectStatus', resp.data.job_id); + } + }) + .catch(error => { + dispatch('requestCreateProjectError', error); + }); +}; + +export const requestCreateProjectStatus = ({ dispatch, state }, jobId) => { + backOffRequest(() => axios.get(state.createProjectStatusEndpoint, { params: { job_id: jobId } })) + .then(resp => { + if (resp.status === statusCodes.OK) { + dispatch('requestCreateProjectSuccess', resp.data); + } + }) + .catch(error => { + dispatch('requestCreateProjectError', error); + }); +}; + +export const requestCreateProjectSuccess = ({ commit }, selfMonitorData) => { + commit(types.SET_LOADING, false); + commit(types.SET_PROJECT_URL, selfMonitorData.project_full_path); + commit(types.SET_ALERT_CONTENT, { + message: s__('SelfMonitoring|Self monitoring project has been successfully created.'), + actionText: __('View project'), + actionName: 'viewSelfMonitorProject', + }); + commit(types.SET_SHOW_ALERT, true); + commit(types.SET_PROJECT_CREATED, true); +}; + +export const requestCreateProjectError = ({ commit }, error) => { + const { response } = error; + const message = response.data && response.data.message ? response.data.message : ''; + + commit(types.SET_ALERT_CONTENT, { + message: `${__('There was an error saving your changes.')} ${message}`, + }); + commit(types.SET_SHOW_ALERT, true); + commit(types.SET_LOADING, false); +}; + +export const deleteProject = ({ dispatch }) => dispatch('requestDeleteProject'); + +export const requestDeleteProject = ({ dispatch, state, commit }) => { + commit(types.SET_LOADING, true); + axios + .delete(state.deleteProjectEndpoint) + .then(resp => { + if (resp.status === statusCodes.ACCEPTED) { + dispatch('requestDeleteProjectStatus', resp.data.job_id); + } + }) + .catch(error => { + dispatch('requestDeleteProjectError', error); + }); +}; + +export const requestDeleteProjectStatus = ({ dispatch, state }, jobId) => { + backOffRequest(() => axios.get(state.deleteProjectStatusEndpoint, { params: { job_id: jobId } })) + .then(resp => { + if (resp.status === statusCodes.OK) { + dispatch('requestDeleteProjectSuccess', resp.data); + } + }) + .catch(error => { + dispatch('requestDeleteProjectError', error); + }); +}; + +export const requestDeleteProjectSuccess = ({ commit }) => { + commit(types.SET_PROJECT_URL, ''); + commit(types.SET_PROJECT_CREATED, false); + commit(types.SET_ALERT_CONTENT, { + message: s__('SelfMonitoring|Self monitoring project has been successfully deleted.'), + actionText: __('Undo'), + actionName: 'createProject', + }); + commit(types.SET_SHOW_ALERT, true); + commit(types.SET_LOADING, false); +}; + +export const requestDeleteProjectError = ({ commit }, error) => { + const { response } = error; + const message = response.data && response.data.message ? response.data.message : ''; + + commit(types.SET_ALERT_CONTENT, { + message: `${__('There was an error saving your changes.')} ${message}`, + }); + commit(types.SET_LOADING, false); +}; diff --git a/app/assets/javascripts/self_monitor/store/index.js b/app/assets/javascripts/self_monitor/store/index.js new file mode 100644 index 00000000000..a222e9c87b8 --- /dev/null +++ b/app/assets/javascripts/self_monitor/store/index.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import createState from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export const createStore = initialState => + new Vuex.Store({ + modules: { + selfMonitoring: { + namespaced: true, + state: createState(initialState), + actions, + mutations, + }, + }, + }); + +export default createStore; diff --git a/app/assets/javascripts/self_monitor/store/mutation_types.js b/app/assets/javascripts/self_monitor/store/mutation_types.js new file mode 100644 index 00000000000..c5952b66144 --- /dev/null +++ b/app/assets/javascripts/self_monitor/store/mutation_types.js @@ -0,0 +1,6 @@ +export const SET_ENABLED = 'SET_ENABLED'; +export const SET_PROJECT_CREATED = 'SET_PROJECT_CREATED'; +export const SET_SHOW_ALERT = 'SET_SHOW_ALERT'; +export const SET_PROJECT_URL = 'SET_PROJECT_URL'; +export const SET_LOADING = 'SET_LOADING'; +export const SET_ALERT_CONTENT = 'SET_ALERT_CONTENT'; diff --git a/app/assets/javascripts/self_monitor/store/mutations.js b/app/assets/javascripts/self_monitor/store/mutations.js new file mode 100644 index 00000000000..7dca8bcdc4d --- /dev/null +++ b/app/assets/javascripts/self_monitor/store/mutations.js @@ -0,0 +1,22 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_ENABLED](state, enabled) { + state.projectEnabled = enabled; + }, + [types.SET_PROJECT_CREATED](state, created) { + state.projectCreated = created; + }, + [types.SET_SHOW_ALERT](state, show) { + state.showAlert = show; + }, + [types.SET_PROJECT_URL](state, url) { + state.projectPath = url; + }, + [types.SET_LOADING](state, loading) { + state.loading = loading; + }, + [types.SET_ALERT_CONTENT](state, content) { + state.alertContent = content; + }, +}; diff --git a/app/assets/javascripts/self_monitor/store/state.js b/app/assets/javascripts/self_monitor/store/state.js new file mode 100644 index 00000000000..b8b4a4af614 --- /dev/null +++ b/app/assets/javascripts/self_monitor/store/state.js @@ -0,0 +1,15 @@ +import { parseBoolean } from '~/lib/utils/common_utils'; + +export default (initialState = {}) => ({ + projectEnabled: parseBoolean(initialState.projectEnabled) || false, + projectCreated: parseBoolean(initialState.selfMonitorProjectCreated) || false, + createProjectEndpoint: initialState.createSelfMonitoringProjectPath || '', + deleteProjectEndpoint: initialState.deleteSelfMonitoringProjectPath || '', + createProjectStatusEndpoint: initialState.statusCreateSelfMonitoringProjectPath || '', + deleteProjectStatusEndpoint: initialState.statusDeleteSelfMonitoringProjectPath || '', + selfMonitorProjectPath: initialState.selfMonitoringProjectFullPath || '', + showAlert: false, + projectPath: '', + loading: false, + alertContent: {}, +}); diff --git a/app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue b/app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue new file mode 100644 index 00000000000..c90478db620 --- /dev/null +++ b/app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue @@ -0,0 +1,43 @@ +<script> +import Stacktrace from '~/error_tracking/components/stacktrace.vue'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { mapActions, mapState, mapGetters } from 'vuex'; + +export default { + name: 'SentryErrorStackTrace', + components: { + Stacktrace, + GlLoadingIcon, + }, + props: { + issueStackTracePath: { + type: String, + required: true, + }, + }, + computed: { + ...mapState('details', ['loadingStacktrace', 'stacktraceData']), + ...mapGetters('details', ['stacktrace']), + }, + mounted() { + this.startPollingStacktrace(this.issueStackTracePath); + }, + methods: { + ...mapActions('details', ['startPollingStacktrace']), + }, +}; +</script> + +<template> + <div> + <div :class="{ 'border-bottom-0': loadingStacktrace }" class="card card-slim mt-4 mb-0"> + <div class="card-header border-bottom-0"> + <h5 class="card-title my-1">{{ __('Stack trace') }}</h5> + </div> + </div> + <div v-if="loadingStacktrace" class="card"> + <gl-loading-icon class="py-2" label="Fetching stack trace" :size="1" /> + </div> + <stacktrace v-else :entries="stacktrace" /> + </div> +</template> diff --git a/app/assets/javascripts/sentry_error_stack_trace/index.js b/app/assets/javascripts/sentry_error_stack_trace/index.js new file mode 100644 index 00000000000..9b24ddc335d --- /dev/null +++ b/app/assets/javascripts/sentry_error_stack_trace/index.js @@ -0,0 +1,22 @@ +import Vue from 'vue'; +import SentryErrorStackTrace from './components/sentry_error_stack_trace.vue'; +import store from '~/error_tracking/store'; + +export default function initSentryErrorStacktrace() { + const sentryErrorStackTraceEl = document.querySelector('#js-sentry-error-stack-trace'); + if (sentryErrorStackTraceEl) { + const { issueStackTracePath } = sentryErrorStackTraceEl.dataset; + // eslint-disable-next-line no-new + new Vue({ + el: sentryErrorStackTraceEl, + components: { + SentryErrorStackTrace, + }, + store, + render: createElement => + createElement('sentry-error-stack-trace', { + props: { issueStackTracePath }, + }), + }); + } +} diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index 308bc4a2ddd..cdbf57f3e55 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -2,7 +2,6 @@ import { mapState, mapActions, mapGetters } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; -import FunctionRow from './function_row.vue'; import EnvironmentRow from './environment_row.vue'; import EmptyState from './empty_state.vue'; import { CHECKING_INSTALLED } from '../constants'; @@ -10,7 +9,6 @@ import { CHECKING_INSTALLED } from '../constants'; export default { components: { EnvironmentRow, - FunctionRow, EmptyState, GlLoadingIcon, }, diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index 38b19d66163..f2ef7a2268e 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -28,6 +28,11 @@ export default { required: false, default: 7, }, + showParticipantLabel: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -80,6 +85,7 @@ export default { <template> <div> <div + v-if="showParticipantLabel" v-tooltip :title="participantLabel" class="sidebar-collapsed-icon" @@ -92,7 +98,7 @@ export default { <gl-loading-icon v-if="loading" class="js-participants-collapsed-loading-icon" /> <span v-else class="js-participants-collapsed-count"> {{ participantCount }} </span> </div> - <div class="title hide-collapsed"> + <div v-if="showParticipantLabel" class="title hide-collapsed"> <gl-loading-icon v-if="loading" :inline="true" diff --git a/app/assets/javascripts/snippets/components/app.vue b/app/assets/javascripts/snippets/components/app.vue index bd2cb8e4595..7a2145a800c 100644 --- a/app/assets/javascripts/snippets/components/app.vue +++ b/app/assets/javascripts/snippets/components/app.vue @@ -1,11 +1,13 @@ <script> import GetSnippetQuery from '../queries/snippet.query.graphql'; import SnippetHeader from './snippet_header.vue'; +import SnippetTitle from './snippet_title.vue'; import { GlLoadingIcon } from '@gitlab/ui'; export default { components: { SnippetHeader, + SnippetTitle, GlLoadingIcon, }, apollo: { @@ -45,6 +47,9 @@ export default { :size="2" class="loading-animation prepend-top-20 append-bottom-20" /> - <snippet-header v-else :snippet="snippet" /> + <template v-else> + <snippet-header :snippet="snippet" /> + <snippet-title :snippet="snippet" /> + </template> </div> </template> diff --git a/app/assets/javascripts/snippets/components/snippet_title.vue b/app/assets/javascripts/snippets/components/snippet_title.vue new file mode 100644 index 00000000000..fc8a9b4a390 --- /dev/null +++ b/app/assets/javascripts/snippets/components/snippet_title.vue @@ -0,0 +1,35 @@ +<script> +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { GlSprintf } from '@gitlab/ui'; + +export default { + components: { + TimeAgoTooltip, + GlSprintf, + }, + props: { + snippet: { + type: Object, + required: true, + }, + }, +}; +</script> +<template> + <div class="snippet-header limited-header-width"> + <h2 class="snippet-title prepend-top-0 mb-3" data-qa-selector="snippet_title"> + {{ snippet.title }} + </h2> + <div v-if="snippet.description" class="description" data-qa-selector="snippet_description"> + <div class="md">{{ snippet.description }}</div> + </div> + + <small v-if="snippet.updatedAt !== snippet.createdAt" class="edited-text"> + <gl-sprintf message="Edited %{timeago}"> + <template #timeago> + <time-ago-tooltip :time="snippet.updatedAt" tooltip-placement="bottom" /> + </template> + </gl-sprintf> + </small> + </div> +</template> diff --git a/app/assets/javascripts/tree.js b/app/assets/javascripts/tree.js index a530c4a99e2..59276ee79d8 100644 --- a/app/assets/javascripts/tree.js +++ b/app/assets/javascripts/tree.js @@ -21,7 +21,9 @@ export default class TreeView { } }); // Show the "Loading commit data" for only the first element - $('span.log_loading:first').removeClass('hide'); + $('span.log_loading') + .first() + .removeClass('hide'); } initKeyNav() { diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 57fbb88ca2e..6d7d863f273 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -27,633 +27,623 @@ function UsersSelect(currentUser, els, options = {}) { } const { handleClick } = options; + const userSelect = this; + + $els.each((i, dropdown) => { + const userSelect = this; + const options = {}; + const $dropdown = $(dropdown); + options.projectId = $dropdown.data('projectId'); + options.groupId = $dropdown.data('groupId'); + options.showCurrentUser = $dropdown.data('currentUser'); + options.todoFilter = $dropdown.data('todoFilter'); + options.todoStateFilter = $dropdown.data('todoStateFilter'); + options.iid = $dropdown.data('iid'); + options.issuableType = $dropdown.data('issuableType'); + const showNullUser = $dropdown.data('nullUser'); + const defaultNullUser = $dropdown.data('nullUserDefault'); + const showMenuAbove = $dropdown.data('showMenuAbove'); + const showAnyUser = $dropdown.data('anyUser'); + const firstUser = $dropdown.data('firstUser'); + options.authorId = $dropdown.data('authorId'); + const defaultLabel = $dropdown.data('defaultLabel'); + const issueURL = $dropdown.data('issueUpdate'); + const $selectbox = $dropdown.closest('.selectbox'); + let $block = $selectbox.closest('.block'); + const abilityName = $dropdown.data('abilityName'); + let $value = $block.find('.value'); + const $collapsedSidebar = $block.find('.sidebar-collapsed-user'); + // eslint-disable-next-line no-jquery/no-fade + const $loading = $block.find('.block-loading').fadeOut(); + const selectedIdDefault = defaultNullUser && showNullUser ? 0 : null; + let selectedId = $dropdown.data('selected'); + let assignTo; + let assigneeTemplate; + let collapsedAssigneeTemplate; + + if (selectedId === undefined) { + selectedId = selectedIdDefault; + } - $els.each( - (function(_this) { - return function(i, dropdown) { - const options = {}; - const $dropdown = $(dropdown); - options.projectId = $dropdown.data('projectId'); - options.groupId = $dropdown.data('groupId'); - options.showCurrentUser = $dropdown.data('currentUser'); - options.todoFilter = $dropdown.data('todoFilter'); - options.todoStateFilter = $dropdown.data('todoStateFilter'); - options.iid = $dropdown.data('iid'); - options.issuableType = $dropdown.data('issuableType'); - const showNullUser = $dropdown.data('nullUser'); - const defaultNullUser = $dropdown.data('nullUserDefault'); - const showMenuAbove = $dropdown.data('showMenuAbove'); - const showAnyUser = $dropdown.data('anyUser'); - const firstUser = $dropdown.data('firstUser'); - options.authorId = $dropdown.data('authorId'); - const defaultLabel = $dropdown.data('defaultLabel'); - const issueURL = $dropdown.data('issueUpdate'); - const $selectbox = $dropdown.closest('.selectbox'); - let $block = $selectbox.closest('.block'); - const abilityName = $dropdown.data('abilityName'); - let $value = $block.find('.value'); - const $collapsedSidebar = $block.find('.sidebar-collapsed-user'); - const $loading = $block.find('.block-loading').fadeOut(); - const selectedIdDefault = defaultNullUser && showNullUser ? 0 : null; - let selectedId = $dropdown.data('selected'); - let assignTo; - let assigneeTemplate; - let collapsedAssigneeTemplate; - - if (selectedId === undefined) { - selectedId = selectedIdDefault; - } - - const assignYourself = function() { - const unassignedSelected = $dropdown - .closest('.selectbox') - .find(`input[name='${$dropdown.data('fieldName')}'][value=0]`); + const assignYourself = function() { + const unassignedSelected = $dropdown + .closest('.selectbox') + .find(`input[name='${$dropdown.data('fieldName')}'][value=0]`); + + if (unassignedSelected) { + unassignedSelected.remove(); + } + + // Save current selected user to the DOM + const currentUserInfo = $dropdown.data('currentUserInfo') || {}; + const currentUser = userSelect.currentUser || {}; + const fieldName = $dropdown.data('fieldName'); + const userName = currentUserInfo.name; + const userId = currentUserInfo.id || currentUser.id; + + const inputHtmlString = _.template(` + <input type="hidden" name="<%- fieldName %>" + data-meta="<%- userName %>" + value="<%- userId %>" /> + `)({ fieldName, userName, userId }); + + if ($selectbox) { + $dropdown.parent().before(inputHtmlString); + } else { + $dropdown.after(inputHtmlString); + } + }; - if (unassignedSelected) { - unassignedSelected.remove(); - } + if ($block[0]) { + $block[0].addEventListener('assignYourself', assignYourself); + } - // Save current selected user to the DOM - const currentUserInfo = $dropdown.data('currentUserInfo') || {}; - const currentUser = _this.currentUser || {}; - const fieldName = $dropdown.data('fieldName'); - const userName = currentUserInfo.name; - const userId = currentUserInfo.id || currentUser.id; - - const inputHtmlString = _.template(` - <input type="hidden" name="<%- fieldName %>" - data-meta="<%- userName %>" - value="<%- userId %>" /> - `)({ fieldName, userName, userId }); - - if ($selectbox) { - $dropdown.parent().before(inputHtmlString); - } else { - $dropdown.after(inputHtmlString); - } - }; + const getSelectedUserInputs = function() { + return $selectbox.find(`input[name="${$dropdown.data('fieldName')}"]`); + }; - if ($block[0]) { - $block[0].addEventListener('assignYourself', assignYourself); - } + const getSelected = function() { + return getSelectedUserInputs() + .map((index, input) => parseInt(input.value, 10)) + .get(); + }; - const getSelectedUserInputs = function() { - return $selectbox.find(`input[name="${$dropdown.data('fieldName')}"]`); - }; - - const getSelected = function() { - return getSelectedUserInputs() - .map((index, input) => parseInt(input.value, 10)) - .get(); - }; - - const checkMaxSelect = function() { - const maxSelect = $dropdown.data('maxSelect'); - if (maxSelect) { - const selected = getSelected(); - - if (selected.length > maxSelect) { - const firstSelectedId = selected[0]; - const firstSelected = $dropdown - .closest('.selectbox') - .find(`input[name='${$dropdown.data('fieldName')}'][value=${firstSelectedId}]`); - - firstSelected.remove(); - emitSidebarEvent('sidebar.removeAssignee', { - id: firstSelectedId, - }); - } - } - }; - - const getMultiSelectDropdownTitle = function(selectedUser, isSelected) { - const selectedUsers = getSelected().filter(u => u !== 0); - - const firstUser = getSelectedUserInputs() - .map((index, input) => ({ - name: input.dataset.meta, - value: parseInt(input.value, 10), - })) - .filter(u => u.id !== 0) - .get(0); - - if (selectedUsers.length === 0) { - return s__('UsersSelect|Unassigned'); - } else if (selectedUsers.length === 1) { - return firstUser.name; - } else if (isSelected) { - const otherSelected = selectedUsers.filter(s => s !== selectedUser.id); - return sprintf(s__('UsersSelect|%{name} + %{length} more'), { - name: selectedUser.name, - length: otherSelected.length, - }); - } else { - return sprintf(s__('UsersSelect|%{name} + %{length} more'), { - name: firstUser.name, - length: selectedUsers.length - 1, - }); - } - }; + const checkMaxSelect = function() { + const maxSelect = $dropdown.data('maxSelect'); + if (maxSelect) { + const selected = getSelected(); - $('.assign-to-me-link').on('click', e => { - e.preventDefault(); - $(e.currentTarget).hide(); + if (selected.length > maxSelect) { + const firstSelectedId = selected[0]; + const firstSelected = $dropdown + .closest('.selectbox') + .find(`input[name='${$dropdown.data('fieldName')}'][value=${firstSelectedId}]`); - if ($dropdown.data('multiSelect')) { - assignYourself(); - checkMaxSelect(); + firstSelected.remove(); + emitSidebarEvent('sidebar.removeAssignee', { + id: firstSelectedId, + }); + } + } + }; - const currentUserInfo = $dropdown.data('currentUserInfo'); - $dropdown - .find('.dropdown-toggle-text') - .text(getMultiSelectDropdownTitle(currentUserInfo)) - .removeClass('is-default'); - } else { - const $input = $(`input[name="${$dropdown.data('fieldName')}"]`); - $input.val(gon.current_user_id); - selectedId = $input.val(); - $dropdown - .find('.dropdown-toggle-text') - .text(gon.current_user_fullname) - .removeClass('is-default'); - } + const getMultiSelectDropdownTitle = function(selectedUser, isSelected) { + const selectedUsers = getSelected().filter(u => u !== 0); + + const firstUser = getSelectedUserInputs() + .map((index, input) => ({ + name: input.dataset.meta, + value: parseInt(input.value, 10), + })) + .filter(u => u.id !== 0) + .get(0); + + if (selectedUsers.length === 0) { + return s__('UsersSelect|Unassigned'); + } else if (selectedUsers.length === 1) { + return firstUser.name; + } else if (isSelected) { + const otherSelected = selectedUsers.filter(s => s !== selectedUser.id); + return sprintf(s__('UsersSelect|%{name} + %{length} more'), { + name: selectedUser.name, + length: otherSelected.length, }); - - $block.on('click', '.js-assign-yourself', e => { - e.preventDefault(); - return assignTo(_this.currentUser.id); + } else { + return sprintf(s__('UsersSelect|%{name} + %{length} more'), { + name: firstUser.name, + length: selectedUsers.length - 1, }); + } + }; - assignTo = function(selected) { - const data = {}; - data[abilityName] = {}; - data[abilityName].assignee_id = selected != null ? selected : null; - $loading.removeClass('hidden').fadeIn(); - $dropdown.trigger('loading.gl.dropdown'); - - return axios.put(issueURL, data).then(({ data }) => { - let user = {}; - let tooltipTitle = user.name; - $dropdown.trigger('loaded.gl.dropdown'); - $loading.fadeOut(); - if (data.assignee) { - user = { - name: data.assignee.name, - username: data.assignee.username, - avatar: data.assignee.avatar_url, - }; - tooltipTitle = _.escape(user.name); - } else { - user = { - name: s__('UsersSelect|Unassigned'), - username: '', - avatar: '', + $('.assign-to-me-link').on('click', e => { + e.preventDefault(); + $(e.currentTarget).hide(); + + if ($dropdown.data('multiSelect')) { + assignYourself(); + checkMaxSelect(); + + const currentUserInfo = $dropdown.data('currentUserInfo'); + $dropdown + .find('.dropdown-toggle-text') + .text(getMultiSelectDropdownTitle(currentUserInfo)) + .removeClass('is-default'); + } else { + const $input = $(`input[name="${$dropdown.data('fieldName')}"]`); + $input.val(gon.current_user_id); + selectedId = $input.val(); + $dropdown + .find('.dropdown-toggle-text') + .text(gon.current_user_fullname) + .removeClass('is-default'); + } + }); + + $block.on('click', '.js-assign-yourself', e => { + e.preventDefault(); + return assignTo(userSelect.currentUser.id); + }); + + assignTo = function(selected) { + const data = {}; + data[abilityName] = {}; + data[abilityName].assignee_id = selected != null ? selected : null; + // eslint-disable-next-line no-jquery/no-fade + $loading.removeClass('hidden').fadeIn(); + $dropdown.trigger('loading.gl.dropdown'); + + return axios.put(issueURL, data).then(({ data }) => { + let user = {}; + let tooltipTitle = user.name; + $dropdown.trigger('loaded.gl.dropdown'); + // eslint-disable-next-line no-jquery/no-fade + $loading.fadeOut(); + if (data.assignee) { + user = { + name: data.assignee.name, + username: data.assignee.username, + avatar: data.assignee.avatar_url, + }; + tooltipTitle = _.escape(user.name); + } else { + user = { + name: s__('UsersSelect|Unassigned'), + username: '', + avatar: '', + }; + tooltipTitle = s__('UsersSelect|Assignee'); + } + $value.html(assigneeTemplate(user)); + $collapsedSidebar.attr('title', tooltipTitle).tooltip('_fixTitle'); + return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); + }); + }; + collapsedAssigneeTemplate = _.template( + '<% if( avatar ) { %> <a class="author-link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>', + ); + assigneeTemplate = _.template( + `<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> + ${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), { + openingTag: '<a href="#" class="js-assign-yourself">', + closingTag: '</a>', + })}</span> <% } %>`, + ); + return $dropdown.glDropdown({ + showMenuAbove, + data(term, callback) { + return userSelect.users(term, options, users => { + // GitLabDropdownFilter returns this.instance + // GitLabDropdownRemote returns this.options.instance + const glDropdown = this.instance || this.options.instance; + glDropdown.options.processData(term, users, callback); + }); + }, + processData(term, data, callback) { + let users = data; + + // Only show assigned user list when there is no search term + if ($dropdown.hasClass('js-multiselect') && term.length === 0) { + const selectedInputs = getSelectedUserInputs(); + + // Potential duplicate entries when dealing with issue board + // because issue board is also managed by vue + const selectedUsers = _.uniq(selectedInputs, false, a => a.value) + .filter(input => { + const userId = parseInt(input.value, 10); + const inUsersArray = users.find(u => u.id === userId); + + return !inUsersArray && userId !== 0; + }) + .map(input => { + const userId = parseInt(input.value, 10); + const { avatarUrl, avatar_url, name, username, canMerge } = input.dataset; + return { + avatar_url: avatarUrl || avatar_url, + id: userId, + name, + username, + can_merge: parseBoolean(canMerge), }; - tooltipTitle = s__('UsersSelect|Assignee'); - } - $value.html(assigneeTemplate(user)); - $collapsedSidebar.attr('title', tooltipTitle).tooltip('_fixTitle'); - return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); - }); - }; - collapsedAssigneeTemplate = _.template( - '<% if( avatar ) { %> <a class="author-link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>', - ); - assigneeTemplate = _.template( - `<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> - ${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), { - openingTag: '<a href="#" class="js-assign-yourself">', - closingTag: '</a>', - })}</span> <% } %>`, - ); - return $dropdown.glDropdown({ - showMenuAbove, - data(term, callback) { - return _this.users(term, options, users => { - // GitLabDropdownFilter returns this.instance - // GitLabDropdownRemote returns this.options.instance - const glDropdown = this.instance || this.options.instance; - glDropdown.options.processData(term, users, callback); }); - }, - processData(term, data, callback) { - let users = data; - - // Only show assigned user list when there is no search term - if ($dropdown.hasClass('js-multiselect') && term.length === 0) { - const selectedInputs = getSelectedUserInputs(); - - // Potential duplicate entries when dealing with issue board - // because issue board is also managed by vue - const selectedUsers = _.uniq(selectedInputs, false, a => a.value) - .filter(input => { - const userId = parseInt(input.value, 10); - const inUsersArray = users.find(u => u.id === userId); - - return !inUsersArray && userId !== 0; - }) - .map(input => { - const userId = parseInt(input.value, 10); - const { avatarUrl, avatar_url, name, username, canMerge } = input.dataset; - return { - avatar_url: avatarUrl || avatar_url, - id: userId, - name, - username, - can_merge: parseBoolean(canMerge), - }; - }); - users = data.concat(selectedUsers); - } + users = data.concat(selectedUsers); + } - let anyUser; - let index; - let len; - let name; - let obj; - let showDivider; - if (term.length === 0) { - showDivider = 0; - if (firstUser) { - // Move current user to the front of the list - for (index = 0, len = users.length; index < len; index += 1) { - obj = users[index]; - if (obj.username === firstUser) { - users.splice(index, 1); - users.unshift(obj); - break; - } - } + let anyUser; + let index; + let len; + let name; + let obj; + let showDivider; + if (term.length === 0) { + showDivider = 0; + if (firstUser) { + // Move current user to the front of the list + for (index = 0, len = users.length; index < len; index += 1) { + obj = users[index]; + if (obj.username === firstUser) { + users.splice(index, 1); + users.unshift(obj); + break; } - if (showNullUser) { + } + } + if (showNullUser) { + showDivider += 1; + users.unshift({ + beforeDivider: true, + name: s__('UsersSelect|Unassigned'), + id: 0, + }); + } + if (showAnyUser) { + showDivider += 1; + name = showAnyUser; + if (name === true) { + name = s__('UsersSelect|Any User'); + } + anyUser = { + beforeDivider: true, + name, + id: null, + }; + users.unshift(anyUser); + } + + if (showDivider) { + users.splice(showDivider, 0, { type: 'divider' }); + } + + if ($dropdown.hasClass('js-multiselect')) { + const selected = getSelected().filter(i => i !== 0); + + if (selected.length > 0) { + if ($dropdown.data('dropdownHeader')) { showDivider += 1; - users.unshift({ - beforeDivider: true, - name: s__('UsersSelect|Unassigned'), - id: 0, + users.splice(showDivider, 0, { + type: 'header', + content: $dropdown.data('dropdownHeader'), }); } - if (showAnyUser) { - showDivider += 1; - name = showAnyUser; - if (name === true) { - name = s__('UsersSelect|Any User'); - } - anyUser = { - beforeDivider: true, - name, - id: null, - }; - users.unshift(anyUser); - } - if (showDivider) { - users.splice(showDivider, 0, { type: 'divider' }); - } + const selectedUsers = users + .filter(u => selected.indexOf(u.id) !== -1) + .sort((a, b) => a.name > b.name); - if ($dropdown.hasClass('js-multiselect')) { - const selected = getSelected().filter(i => i !== 0); + users = users.filter(u => selected.indexOf(u.id) === -1); - if (selected.length > 0) { - if ($dropdown.data('dropdownHeader')) { - showDivider += 1; - users.splice(showDivider, 0, { - type: 'header', - content: $dropdown.data('dropdownHeader'), - }); - } + selectedUsers.forEach(selectedUser => { + showDivider += 1; + users.splice(showDivider, 0, selectedUser); + }); - const selectedUsers = users - .filter(u => selected.indexOf(u.id) !== -1) - .sort((a, b) => a.name > b.name); + users.splice(showDivider + 1, 0, { type: 'divider' }); + } + } + } - users = users.filter(u => selected.indexOf(u.id) === -1); + callback(users); + if (showMenuAbove) { + $dropdown.data('glDropdown').positionMenuAbove(); + } + }, + filterable: true, + filterRemote: true, + search: { + fields: ['name', 'username'], + }, + selectable: true, + fieldName: $dropdown.data('fieldName'), + toggleLabel(selected, el, glDropdown) { + const inputValue = glDropdown.filterInput.val(); + + if (this.multiSelect && inputValue === '') { + // Remove non-users from the fullData array + const users = glDropdown.filteredFullData(); + const callback = glDropdown.parseData.bind(glDropdown); + + // Update the data model + this.processData(inputValue, users, callback); + } - selectedUsers.forEach(selectedUser => { - showDivider += 1; - users.splice(showDivider, 0, selectedUser); - }); + if (this.multiSelect) { + return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active')); + } - users.splice(showDivider + 1, 0, { type: 'divider' }); - } - } - } + if (selected && 'id' in selected && $(el).hasClass('is-active')) { + $dropdown.find('.dropdown-toggle-text').removeClass('is-default'); + if (selected.text) { + return selected.text; + } else { + return selected.name; + } + } else { + $dropdown.find('.dropdown-toggle-text').addClass('is-default'); + return defaultLabel; + } + }, + defaultLabel, + hidden() { + if ($dropdown.hasClass('js-multiselect')) { + emitSidebarEvent('sidebar.saveAssignees'); + } - callback(users); - if (showMenuAbove) { - $dropdown.data('glDropdown').positionMenuAbove(); - } - }, - filterable: true, - filterRemote: true, - search: { - fields: ['name', 'username'], - }, - selectable: true, - fieldName: $dropdown.data('fieldName'), - toggleLabel(selected, el, glDropdown) { - const inputValue = glDropdown.filterInput.val(); - - if (this.multiSelect && inputValue === '') { - // Remove non-users from the fullData array - const users = glDropdown.filteredFullData(); - const callback = glDropdown.parseData.bind(glDropdown); - - // Update the data model - this.processData(inputValue, users, callback); - } + if (!$dropdown.data('alwaysShowSelectbox')) { + $selectbox.hide(); - if (this.multiSelect) { - return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active')); - } + // Recalculate where .value is because vue might have changed it + $block = $selectbox.closest('.block'); + $value = $block.find('.value'); + // display:block overrides the hide-collapse rule + $value.css('display', ''); + } + }, + multiSelect: $dropdown.hasClass('js-multiselect'), + inputMeta: $dropdown.data('inputMeta'), + clicked(options) { + const { $el, e, isMarking } = options; + const user = options.selectedObj; + + $el.tooltip('dispose'); + + if ($dropdown.hasClass('js-multiselect')) { + const isActive = $el.hasClass('is-active'); + const previouslySelected = $dropdown + .closest('.selectbox') + .find(`input[name='${$dropdown.data('fieldName')}'][value!=0]`); - if (selected && 'id' in selected && $(el).hasClass('is-active')) { - $dropdown.find('.dropdown-toggle-text').removeClass('is-default'); - if (selected.text) { - return selected.text; - } else { - return selected.name; - } - } else { - $dropdown.find('.dropdown-toggle-text').addClass('is-default'); - return defaultLabel; + // Enables support for limiting the number of users selected + // Automatically removes the first on the list if more users are selected + checkMaxSelect(); + + if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') { + // Unassigned selected + previouslySelected.each((index, element) => { + element.remove(); + }); + emitSidebarEvent('sidebar.removeAllAssignees'); + } else if (isActive) { + // user selected + emitSidebarEvent('sidebar.addAssignee', user); + + // Remove unassigned selection (if it was previously selected) + const unassignedSelected = $dropdown + .closest('.selectbox') + .find(`input[name='${$dropdown.data('fieldName')}'][value=0]`); + + if (unassignedSelected) { + unassignedSelected.remove(); } - }, - defaultLabel, - hidden() { - if ($dropdown.hasClass('js-multiselect')) { - emitSidebarEvent('sidebar.saveAssignees'); + } else { + if (previouslySelected.length === 0) { + // Select unassigned because there is no more selected users + this.addInput($dropdown.data('fieldName'), 0, {}); } - if (!$dropdown.data('alwaysShowSelectbox')) { - $selectbox.hide(); + // User unselected + emitSidebarEvent('sidebar.removeAssignee', user); + } - // Recalculate where .value is because vue might have changed it - $block = $selectbox.closest('.block'); - $value = $block.find('.value'); - // display:block overrides the hide-collapse rule - $value.css('display', ''); - } - }, - multiSelect: $dropdown.hasClass('js-multiselect'), - inputMeta: $dropdown.data('inputMeta'), - clicked(options) { - const { $el, e, isMarking } = options; - const user = options.selectedObj; - - $el.tooltip('dispose'); - - if ($dropdown.hasClass('js-multiselect')) { - const isActive = $el.hasClass('is-active'); - const previouslySelected = $dropdown - .closest('.selectbox') - .find(`input[name='${$dropdown.data('fieldName')}'][value!=0]`); - - // Enables support for limiting the number of users selected - // Automatically removes the first on the list if more users are selected - checkMaxSelect(); - - if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') { - // Unassigned selected - previouslySelected.each((index, element) => { - element.remove(); - }); - emitSidebarEvent('sidebar.removeAllAssignees'); - } else if (isActive) { - // user selected - emitSidebarEvent('sidebar.addAssignee', user); - - // Remove unassigned selection (if it was previously selected) - const unassignedSelected = $dropdown - .closest('.selectbox') - .find(`input[name='${$dropdown.data('fieldName')}'][value=0]`); - - if (unassignedSelected) { - unassignedSelected.remove(); - } - } else { - if (previouslySelected.length === 0) { - // Select unassigned because there is no more selected users - this.addInput($dropdown.data('fieldName'), 0, {}); - } + if (getSelected().find(u => u === gon.current_user_id)) { + $('.assign-to-me-link').hide(); + } else { + $('.assign-to-me-link').show(); + } + } - // User unselected - emitSidebarEvent('sidebar.removeAssignee', user); - } + const page = $('body').attr('data-page'); + const isIssueIndex = page === 'projects:issues:index'; + const isMRIndex = page === page && page === 'projects:merge_requests:index'; + if ( + $dropdown.hasClass('js-filter-bulk-update') || + $dropdown.hasClass('js-issuable-form-dropdown') + ) { + e.preventDefault(); - if (getSelected().find(u => u === gon.current_user_id)) { - $('.assign-to-me-link').hide(); - } else { - $('.assign-to-me-link').show(); - } - } + const isSelecting = user.id !== selectedId; + selectedId = isSelecting ? user.id : selectedIdDefault; - const page = $('body').attr('data-page'); - const isIssueIndex = page === 'projects:issues:index'; - const isMRIndex = page === page && page === 'projects:merge_requests:index'; - if ( - $dropdown.hasClass('js-filter-bulk-update') || - $dropdown.hasClass('js-issuable-form-dropdown') - ) { - e.preventDefault(); - - const isSelecting = user.id !== selectedId; - selectedId = isSelecting ? user.id : selectedIdDefault; - - if (selectedId === gon.current_user_id) { - $('.assign-to-me-link').hide(); - } else { - $('.assign-to-me-link').show(); - } - return; - } - if ($el.closest('.add-issues-modal').length) { - ModalStore.store.filter[$dropdown.data('fieldName')] = user.id; - } else if (handleClick) { - e.preventDefault(); - handleClick(user, isMarking); - } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { - return Issuable.filterResults($dropdown.closest('form')); - } else if ($dropdown.hasClass('js-filter-submit')) { - return $dropdown.closest('form').submit(); - } else if (!$dropdown.hasClass('js-multiselect')) { - const selected = $dropdown - .closest('.selectbox') - .find(`input[name='${$dropdown.data('fieldName')}']`) - .val(); - return assignTo(selected); - } + if (selectedId === gon.current_user_id) { + $('.assign-to-me-link').hide(); + } else { + $('.assign-to-me-link').show(); + } + return; + } + if ($el.closest('.add-issues-modal').length) { + ModalStore.store.filter[$dropdown.data('fieldName')] = user.id; + } else if (handleClick) { + e.preventDefault(); + handleClick(user, isMarking); + } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + return Issuable.filterResults($dropdown.closest('form')); + } else if ($dropdown.hasClass('js-filter-submit')) { + return $dropdown.closest('form').submit(); + } else if (!$dropdown.hasClass('js-multiselect')) { + const selected = $dropdown + .closest('.selectbox') + .find(`input[name='${$dropdown.data('fieldName')}']`) + .val(); + return assignTo(selected); + } - // Automatically close dropdown after assignee is selected - // since CE has no multiple assignees - // EE does not have a max-select - if ( - $dropdown.data('maxSelect') && - getSelected().length === $dropdown.data('maxSelect') - ) { - // Close the dropdown - $dropdown.dropdown('toggle'); - } - }, - id(user) { - return user.id; - }, - opened(e) { - const $el = $(e.currentTarget); - const selected = getSelected(); - if ($dropdown.hasClass('js-issue-board-sidebar') && selected.length === 0) { - this.addInput($dropdown.data('fieldName'), 0, {}); - } - $el.find('.is-active').removeClass('is-active'); + // Automatically close dropdown after assignee is selected + // since CE has no multiple assignees + // EE does not have a max-select + if ($dropdown.data('maxSelect') && getSelected().length === $dropdown.data('maxSelect')) { + // Close the dropdown + $dropdown.dropdown('toggle'); + } + }, + id(user) { + return user.id; + }, + opened(e) { + const $el = $(e.currentTarget); + const selected = getSelected(); + if ($dropdown.hasClass('js-issue-board-sidebar') && selected.length === 0) { + this.addInput($dropdown.data('fieldName'), 0, {}); + } + $el.find('.is-active').removeClass('is-active'); - function highlightSelected(id) { - $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active'); - } + function highlightSelected(id) { + $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active'); + } - if (selected.length > 0) { - getSelected().forEach(selectedId => highlightSelected(selectedId)); - } else if ($dropdown.hasClass('js-issue-board-sidebar')) { - highlightSelected(0); - } else { - highlightSelected(selectedId); - } - }, - updateLabel: $dropdown.data('dropdownTitle'), - renderRow(user) { - const username = user.username ? `@${user.username}` : ''; - const avatar = user.avatar_url ? user.avatar_url : gon.default_avatar_url; + if (selected.length > 0) { + getSelected().forEach(selectedId => highlightSelected(selectedId)); + } else if ($dropdown.hasClass('js-issue-board-sidebar')) { + highlightSelected(0); + } else { + highlightSelected(selectedId); + } + }, + updateLabel: $dropdown.data('dropdownTitle'), + renderRow(user) { + const username = user.username ? `@${user.username}` : ''; + const avatar = user.avatar_url ? user.avatar_url : gon.default_avatar_url; - let selected = false; + let selected = false; - if (this.multiSelect) { - selected = getSelected().find(u => user.id === u); + if (this.multiSelect) { + selected = getSelected().find(u => user.id === u); - const { fieldName } = this; - const field = $dropdown - .closest('.selectbox') - .find(`input[name='${fieldName}'][value='${user.id}']`); + const { fieldName } = this; + const field = $dropdown + .closest('.selectbox') + .find(`input[name='${fieldName}'][value='${user.id}']`); - if (field.length) { - selected = true; - } - } else { - selected = user.id === selectedId; - } + if (field.length) { + selected = true; + } + } else { + selected = user.id === selectedId; + } - let img = ''; - if (user.beforeDivider != null) { - `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${_.escape( - user.name, - )}</a></li>`; - } else { - // 0 margin, because it's now handled by a wrapper - img = `<img src='${avatar}' class='avatar avatar-inline m-0' width='32' />`; - } + let img = ''; + if (user.beforeDivider != null) { + `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${_.escape( + user.name, + )}</a></li>`; + } else { + // 0 margin, because it's now handled by a wrapper + img = `<img src='${avatar}' class='avatar avatar-inline m-0' width='32' />`; + } - return _this.renderRow(options.issuableType, user, selected, username, img); - }, - }); - }; - })(this), - ); + return userSelect.renderRow(options.issuableType, user, selected, username, img); + }, + }); + }); import(/* webpackChunkName: 'select2' */ 'select2/select2') .then(() => { - $('.ajax-users-select').each( - (function(_this) { - return function(i, select) { - const options = {}; - options.skipLdap = $(select).hasClass('skip_ldap'); - options.projectId = $(select).data('projectId'); - options.groupId = $(select).data('groupId'); - options.showCurrentUser = $(select).data('currentUser'); - options.authorId = $(select).data('authorId'); - options.skipUsers = $(select).data('skipUsers'); - const showNullUser = $(select).data('nullUser'); - const showAnyUser = $(select).data('anyUser'); - const showEmailUser = $(select).data('emailUser'); - const firstUser = $(select).data('firstUser'); - return $(select).select2({ - placeholder: __('Search for a user'), - multiple: $(select).hasClass('multiselect'), - minimumInputLength: 0, - query(query) { - return _this.users(query.term, options, users => { - let name; - const data = { - results: users, - }; - if (query.term.length === 0) { - if (firstUser) { - // Move current user to the front of the list - const ref = data.results; - - for (let index = 0, len = ref.length; index < len; index += 1) { - const obj = ref[index]; - if (obj.username === firstUser) { - data.results.splice(index, 1); - data.results.unshift(obj); - break; - } - } - } - if (showNullUser) { - const nullUser = { - name: s__('UsersSelect|Unassigned'), - id: 0, - }; - data.results.unshift(nullUser); - } - if (showAnyUser) { - name = showAnyUser; - if (name === true) { - name = s__('UsersSelect|Any User'); - } - const anyUser = { - name, - id: null, - }; - data.results.unshift(anyUser); + $('.ajax-users-select').each((i, select) => { + const options = {}; + options.skipLdap = $(select).hasClass('skip_ldap'); + options.projectId = $(select).data('projectId'); + options.groupId = $(select).data('groupId'); + options.showCurrentUser = $(select).data('currentUser'); + options.authorId = $(select).data('authorId'); + options.skipUsers = $(select).data('skipUsers'); + const showNullUser = $(select).data('nullUser'); + const showAnyUser = $(select).data('anyUser'); + const showEmailUser = $(select).data('emailUser'); + const firstUser = $(select).data('firstUser'); + return $(select).select2({ + placeholder: __('Search for a user'), + multiple: $(select).hasClass('multiselect'), + minimumInputLength: 0, + query(query) { + return userSelect.users(query.term, options, users => { + let name; + const data = { + results: users, + }; + if (query.term.length === 0) { + if (firstUser) { + // Move current user to the front of the list + const ref = data.results; + + for (let index = 0, len = ref.length; index < len; index += 1) { + const obj = ref[index]; + if (obj.username === firstUser) { + data.results.splice(index, 1); + data.results.unshift(obj); + break; } } - if ( - showEmailUser && - data.results.length === 0 && - query.term.match(/^[^@]+@[^@]+$/) - ) { - const trimmed = query.term.trim(); - const emailUser = { - name: sprintf(__('Invite "%{trimmed}" by email'), { trimmed }), - username: trimmed, - id: trimmed, - invite: true, - }; - data.results.unshift(emailUser); + } + if (showNullUser) { + const nullUser = { + name: s__('UsersSelect|Unassigned'), + id: 0, + }; + data.results.unshift(nullUser); + } + if (showAnyUser) { + name = showAnyUser; + if (name === true) { + name = s__('UsersSelect|Any User'); } - return query.callback(data); - }); - }, - initSelection() { - const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return _this.initSelection.apply(_this, args); - }, - formatResult() { - const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return _this.formatResult.apply(_this, args); - }, - formatSelection() { - const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return _this.formatSelection.apply(_this, args); - }, - dropdownCssClass: 'ajax-users-dropdown', - // we do not want to escape markup since we are displaying html in results - escapeMarkup(m) { - return m; - }, + const anyUser = { + name, + id: null, + }; + data.results.unshift(anyUser); + } + } + if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) { + const trimmed = query.term.trim(); + const emailUser = { + name: sprintf(__('Invite "%{trimmed}" by email'), { trimmed }), + username: trimmed, + id: trimmed, + invite: true, + }; + data.results.unshift(emailUser); + } + return query.callback(data); }); - }; - })(this), - ); + }, + initSelection() { + const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return userSelect.initSelection.apply(userSelect, args); + }, + formatResult() { + const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return userSelect.formatResult.apply(userSelect, args); + }, + formatSelection() { + const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return userSelect.formatSelection.apply(userSelect, args); + }, + dropdownCssClass: 'ajax-users-dropdown', + // we do not want to escape markup since we are displaying html in results + escapeMarkup(m) { + return m; + }, + }); + }); }) .catch(() => {}); } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue index e03b1e6d6a6..34866cdfa6f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue @@ -1,5 +1,5 @@ <script> -import { GlTooltipDirective } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; import DeploymentInfo from './deployment_info.vue'; import DeploymentViewButton from './deployment_view_button.vue'; import DeploymentStopButton from './deployment_stop_button.vue'; @@ -14,9 +14,6 @@ export default { DeploymentStopButton, DeploymentViewButton, }, - directives: { - GlTooltip: GlTooltipDirective, - }, props: { deployment: { type: Object, @@ -43,6 +40,14 @@ export default { }, }, computed: { + appButtonText() { + return { + text: this.isCurrent ? s__('Review App|View app') : s__('Review App|View latest app'), + tooltip: this.isCurrent + ? '' + : __('View the latest successful deployment to this environment'), + }; + }, canBeManuallyDeployed() { return this.computedDeploymentStatus === MANUAL_DEPLOY; }, @@ -55,9 +60,6 @@ export default { hasExternalUrls() { return Boolean(this.deployment.external_url && this.deployment.external_url_formatted); }, - hasPreviousDeployment() { - return Boolean(!this.isCurrent && this.deployment.deployed_at); - }, isCurrent() { return this.computedDeploymentStatus === SUCCESS; }, @@ -89,7 +91,7 @@ export default { <!-- show appropriate version of review app button --> <deployment-view-button v-if="hasExternalUrls" - :is-current="isCurrent" + :app-button-text="appButtonText" :deployment="deployment" :show-visual-review-app="showVisualReviewApp" :visual-review-app-metadata="visualReviewAppMeta" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue index 9965e3d5203..18d4073ecd4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue @@ -11,12 +11,12 @@ export default { import('ee_component/vue_merge_request_widget/components/visual_review_app_link.vue'), }, props: { - deployment: { + appButtonText: { type: Object, required: true, }, - isCurrent: { - type: Boolean, + deployment: { + type: Object, required: true, }, showVisualReviewApp: { @@ -60,7 +60,7 @@ export default { > <template slot="mainAction" slot-scope="slotProps"> <review-app-link - :is-current="isCurrent" + :display="appButtonText" :link="deploymentExternalUrl" :css-class="`deploy-link js-deploy-url inline ${slotProps.className}`" /> @@ -85,7 +85,7 @@ export default { </filtered-search-dropdown> <template v-else> <review-app-link - :is-current="isCurrent" + :display="appButtonText" :link="deploymentExternalUrl" css-class="js-deploy-url deploy-link btn btn-default btn-sm inline" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue index 36f291e995c..96603d23374 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue @@ -1,12 +1,11 @@ <script> -import { GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; export default { components: { GlButton, - GlLink, GlLoadingIcon, Icon, }, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue index 1550ec0f21e..c38c41f13b6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue @@ -1,18 +1,21 @@ <script> -import { __ } from '~/locale'; +import { GlTooltipDirective } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; export default { components: { Icon, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { cssClass: { type: String, required: true, }, - isCurrent: { - type: Boolean, + display: { + type: Object, required: true, }, link: { @@ -20,15 +23,12 @@ export default { required: true, }, }, - computed: { - linkText() { - return this.isCurrent ? __('View app') : __('View previous app'); - }, - }, }; </script> <template> <a + v-gl-tooltip + :title="display.tooltip" :href="link" target="_blank" rel="noopener noreferrer nofollow" @@ -36,6 +36,6 @@ export default { data-track-event="open_review_app" data-track-label="review_app" > - {{ linkText }} <icon class="fgray" name="external-link" /> + {{ display.text }} <icon class="fgray" name="external-link" /> </a> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue index 11bc8c73ee9..75d1e5865b0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue @@ -84,7 +84,12 @@ export default { <span v-else> {{ s__('mrWidget|Merge failed.') }} </span> <span :class="{ 'has-custom-error': mr.mergeError }"> {{ timerText }} </span> </span> - <button class="btn btn-default btn-sm js-refresh-button" type="button" @click="refresh"> + <button + class="btn btn-default btn-sm js-refresh-button" + data-qa-selector="merge_request_error_content" + type="button" + @click="refresh" + > {{ s__('mrWidget|Refresh now') }} </button> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue index e9aac8b385c..8f38ca69453 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue @@ -2,7 +2,6 @@ import { sprintf, s__ } from '~/locale'; import tooltip from '~/vue_shared/directives/tooltip'; import statusIcon from '../mr_widget_status_icon.vue'; -import mrWidgetMergeHelp from '../../components/mr_widget_merge_help.vue'; export default { name: 'MRWidgetMissingBranch', @@ -10,7 +9,6 @@ export default { tooltip, }, components: { - mrWidgetMergeHelp, statusIcon, }, props: { diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue index 75c3c544c77..09cffc57688 100644 --- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue @@ -36,12 +36,17 @@ export default { required: false, default: true, }, + showChangedStatus: { + type: Boolean, + required: false, + default: false, + }, }, computed: { changedIcon() { // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings - const suffix = !this.file.changed && this.file.staged && this.showStagedIcon ? '-solid' : ''; + const suffix = this.showStagedIcon ? '-solid' : ''; return `${getCommitIconMap(this.file).icon}${suffix}`; }, @@ -86,8 +91,8 @@ export default { <span v-gl-tooltip.right :title="tooltipTitle" - :class="{ 'ml-auto': isCentered }" - class="file-changed-icon d-inline-block" + :class="[{ 'ml-auto': isCentered }, changedIconClass]" + class="file-changed-icon d-flex align-items-center " > <icon v-if="showIcon" :name="changedIcon" :size="size" :class="changedIconClass" /> </span> diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 611001df32f..0c9f6ea94d5 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -1,5 +1,4 @@ <script> -import Icon from '~/vue_shared/components/icon.vue'; import FileHeader from '~/vue_shared/components/file_row_header.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; @@ -9,7 +8,6 @@ export default { components: { FileHeader, FileIcon, - Icon, ChangedFileIcon, }, props: { @@ -26,6 +24,7 @@ export default { required: false, default: null, }, + hideExtraOnTree: { type: Boolean, required: false, @@ -143,17 +142,17 @@ export default { @mouseleave="toggleDropdown(false)" > <div class="file-row-name-container"> - <span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated"> + <span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated d-flex"> <file-icon v-if="!showChangedIcon || file.type === 'tree'" - class="file-row-icon" + class="file-row-icon text-secondary mr-1" :file-name="file.name" :loading="file.loading" :folder="isTree" :opened="file.opened" :size="16" /> - <changed-file-icon v-else :file="file" :size="16" class="append-right-5" /> + <file-icon v-else :file-name="file.name" :size="16" css-classes="top mr-1" /> {{ file.name }} </span> <component @@ -163,6 +162,7 @@ export default { :dropdown-open="dropdownOpen" @toggle="toggleDropdown($event)" /> + <changed-file-icon :file="file" :size="16" class="append-right-5" /> </div> </div> <template v-if="file.opened || file.isHeader"> @@ -172,7 +172,6 @@ export default { :file="childFile" :level="childFilesLevel" :hide-extra-on-tree="hideExtraOnTree" - :extra-component="extraComponent" :show-changed-icon="showChangedIcon" @toggleTreeOpen="toggleTreeOpen" @clickFile="clickedFile" diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index c652a684d7c..dba4a9231a1 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -117,28 +117,7 @@ export default { <section v-if="actions.length" class="header-action-buttons"> <template v-for="(action, i) in actions"> - <gl-link - v-if="action.type === 'link'" - :key="i" - :href="action.path" - :class="action.cssClass" - > - {{ action.label }} - </gl-link> - - <gl-link - v-else-if="action.type === 'ujs-link'" - :key="i" - :href="action.path" - :class="action.cssClass" - data-method="post" - rel="nofollow" - > - {{ action.label }} - </gl-link> - <loading-button - v-else-if="action.type === 'button'" :key="i" :loading="action.isLoading" :disabled="action.isLoading" diff --git a/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js b/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js index d1aba99ac22..188ab1769a4 100644 --- a/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js +++ b/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js @@ -12,6 +12,7 @@ function cleanSuggestionLine(line = {}) { return { ...line, text: trimFirstCharOfLineContent(line.text), + rich_text: trimFirstCharOfLineContent(line.rich_text), }; } diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 326440f5013..4f5f3ee5cf9 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -20,6 +20,11 @@ export default { Suggestions, }, props: { + isSubmitting: { + type: Boolean, + required: false, + default: false, + }, markdownPreviewPath: { type: String, required: false, @@ -133,6 +138,20 @@ export default { ); }, }, + watch: { + isSubmitting(isSubmitting) { + if (!isSubmitting || !this.$refs['markdown-preview'].querySelectorAll) { + return; + } + const mediaInPreview = this.$refs['markdown-preview'].querySelectorAll('video, audio'); + + if (mediaInPreview) { + mediaInPreview.forEach(media => { + media.pause(); + }); + } + }, + }, mounted() { /* GLForm class handles all the toolbar buttons @@ -177,7 +196,6 @@ export default { this.renderMarkdown(); } }, - showWriteTab() { this.markdownPreview = ''; this.previewMarkdown = false; diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index af4ac024e4f..fee5d6d5e3a 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -136,7 +136,11 @@ export default { > <strong>{{ __('New! Suggest changes directly') }}</strong> <p class="mb-2"> - {{ __('Suggest code changes which are immediately applied. Try it out!') }} + {{ + __( + 'Suggest code changes which can be immediately applied in one click. Try it out!', + ) + }} </p> <gl-button variant="primary" size="sm" @click="handleSuggestDismissed"> {{ __('Got it') }} diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue index c09bdfec250..97d93eaaf3f 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue @@ -24,7 +24,8 @@ export default { {{ line.new_line }} </td> <td class="line_content" :class="lineType"> - <span v-if="line.text">{{ line.text }}</span> + <span v-if="line.rich_text" v-html="line.rich_text"></span> + <span v-else-if="line.text">{{ line.text }}</span> <!-- TODO: replace this hack with zero-width whitespace when we have rich_text from BE --> <span v-else>​</span> </td> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index 7f0fcfac071..20a14d78f9b 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -5,7 +5,6 @@ import SuggestionDiff from './suggestion_diff.vue'; import Flash from '~/flash'; export default { - components: { SuggestionDiff }, props: { lineType: { type: String, diff --git a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue index e61d1fd2031..e75ac8c54bc 100644 --- a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue @@ -12,7 +12,7 @@ export default { </script> <template> - <timeline-entry-item class="note note-wrapper"> + <timeline-entry-item class="note note-wrapper" data-qa-selector="skeleton_note"> <div class="timeline-icon"></div> <div class="timeline-content"> <div class="note-header"></div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue index 282b181f11e..f519f90445e 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue @@ -1,10 +1,9 @@ <script> -import { GlLink, GlTooltip } from '@gitlab/ui'; +import { GlTooltip } from '@gitlab/ui'; export default { components: { GlTooltip, - GlLink, }, props: { label: { diff --git a/app/assets/javascripts/vue_shared/components/split_button.vue b/app/assets/javascripts/vue_shared/components/split_button.vue index 9aacde49264..f02b412e8a1 100644 --- a/app/assets/javascripts/vue_shared/components/split_button.vue +++ b/app/assets/javascripts/vue_shared/components/split_button.vue @@ -49,6 +49,10 @@ export default { triggerEvent() { this.$emit(this.selectedItem.eventName); }, + changeSelectedItem(item) { + this.selectedItem = item; + this.$emit('change', item); + }, }, }; </script> @@ -67,7 +71,7 @@ export default { :key="item.eventName" :active="selectedItem === item" active-class="is-active" - @click="selectedItem = item" + @click="changeSelectedItem(item)" > <strong>{{ item.title }}</strong> <div>{{ item.description }}</div> diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 4a72cca5f02..37e3643bf6c 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -30,11 +30,16 @@ export default { }, computed: { statusHtml() { + if (!this.user.status) { + return ''; + } + if (this.user.status.emoji && this.user.status.message_html) { return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message_html}`; } else if (this.user.status.message_html) { return this.user.status.message_html; } + return ''; }, nameIsLoading() { @@ -97,7 +102,9 @@ export default { class="animation-container-small mb-1" /> </div> - <div v-if="user.status" class="mt-2"><span v-html="statusHtml"></span></div> + <div v-if="statusHtml" class="js-user-status mt-2"> + <span v-html="statusHtml"></span> + </div> </div> </div> </gl-popover> diff --git a/app/assets/javascripts/vuex_shared/bindings.js b/app/assets/javascripts/vuex_shared/bindings.js new file mode 100644 index 00000000000..817a90f8149 --- /dev/null +++ b/app/assets/javascripts/vuex_shared/bindings.js @@ -0,0 +1,36 @@ +/** + * Returns computed properties two way bound to vuex + * + * @param {(string[]|Object[])} list - list of string matching state keys or list objects + * @param {string} list[].key - the key matching the key present in the vuex state + * @param {string} list[].getter - the name of the getter, leave it empty to not use a getter + * @param {string} list[].updateFn - the name of the action, leave it empty to use the default action + * @param {string} defaultUpdateFn - the default function to dispatch + * @param {string} root - the key of the state where to search fo they keys described in list + * @returns {Object} a dictionary with all the computed properties generated + */ +export const mapComputed = (list, defaultUpdateFn, root) => { + const result = {}; + list.forEach(item => { + const [getter, key, updateFn] = + typeof item === 'string' + ? [false, item, defaultUpdateFn] + : [item.getter, item.key, item.updateFn || defaultUpdateFn]; + result[key] = { + get() { + if (getter) { + return this.$store.getters[getter]; + } else if (root) { + return this.$store.state[root][key]; + } + return this.$store.state[key]; + }, + set(value) { + this.$store.dispatch(updateFn, { [key]: value }); + }, + }; + }); + return result; +}; + +export default () => {}; diff --git a/app/assets/stylesheets/test.scss b/app/assets/stylesheets/disable_animations.scss index e65b49c36f3..e65b49c36f3 100644 --- a/app/assets/stylesheets/test.scss +++ b/app/assets/stylesheets/disable_animations.scss diff --git a/app/assets/stylesheets/framework/broadcast_messages.scss b/app/assets/stylesheets/framework/broadcast_messages.scss index d3e7d751e63..95ea3d90a0e 100644 --- a/app/assets/stylesheets/framework/broadcast_messages.scss +++ b/app/assets/stylesheets/framework/broadcast_messages.scss @@ -1,7 +1,5 @@ .broadcast-message { - @extend .alert-warning; - padding: 10px; - text-align: center; + padding: $gl-padding-8; div, p { @@ -15,9 +13,29 @@ } } -.broadcast-message-preview { +.broadcast-banner-message { + @extend .broadcast-message; + @extend .alert-warning; + text-align: center; +} + +.broadcast-notification-message { @extend .broadcast-message; - margin-bottom: 20px; + + position: fixed; + bottom: $gl-padding; + right: $gl-padding; + max-width: 300px; + width: auto; + background: $white-light; + border: 1px solid $gray-200; + box-shadow: 0 1px 2px 0 rgba($black, 0.1); + border-radius: $border-radius-default; + z-index: 999; + + &.preview { + position: static; + } } .toggle-colors { diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 767832e242c..1b549c0a4f0 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -29,7 +29,7 @@ &:focus, &:active { background-color: $btn-active-gray; - box-shadow: $gl-btn-active-background; + box-shadow: none; } } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 4b7dda3a2ff..dc119b52f4e 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -55,6 +55,10 @@ background-color: $gray-light; } +.bg-white { + background-color: $white; +} + .bg-line-target-blue { background: $line-target-blue; } @@ -456,6 +460,8 @@ img.emoji { .w-8em { width: 8em; } .w-3rem { width: 3rem; } .w-15p { width: 15%; } +.w-30p { width: 30%; } +.w-60p { width: 60%; } .w-70p { width: 70%; } .h-12em { height: 12em; } @@ -573,6 +579,7 @@ img.emoji { .gl-font-size-large { font-size: $gl-font-size-large; } .gl-line-height-24 { line-height: $gl-line-height-24; } +.gl-line-height-14 { line-height: $gl-line-height-14; } .gl-font-size-12 { font-size: $gl-font-size-12; } .gl-font-size-14 { font-size: $gl-font-size-14; } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 8e0314bc6da..1a017f03ebb 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -207,6 +207,14 @@ border-left-color: mix($blame-gray, $blame-cyan, $i / 4 * 100%); } } + + .doc-versions { + color: $gray-600; + + &:hover { + color: $gray-900; + } + } } &.logs { diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 1c252584047..b5d1c3f6732 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -88,6 +88,7 @@ } .name, + .operator, .value { display: inline-block; padding: 2px 7px; @@ -101,6 +102,12 @@ text-transform: capitalize; } + .operator { + background-color: $white-normal; + color: $filter-value-text-color; + margin-right: 1px; + } + .value-container { display: flex; align-items: center; @@ -147,6 +154,10 @@ background-color: $filter-name-selected-color; } + .operator { + box-shadow: inset 0 0 0 100px $filtered-search-term-shadow-color; + } + .value-container { box-shadow: inset 0 0 0 100px $filtered-search-term-shadow-color; } @@ -260,6 +271,11 @@ max-width: none; min-width: 100%; } + + .btn-helptext { + margin-left: auto; + color: var(--gray); + } } .filtered-search-history-dropdown-wrapper { diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index 58516cbd1a9..ee6e53adaf7 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -8,7 +8,7 @@ pre { padding: 10px 0; border: 0; - border-radius: 0 0 $border-radius-default $border-radius-default; + border-radius: 0 0 $border-radius-default; font-family: $monospace-font; font-size: $code-font-size; line-height: 1.5; diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index a53f5d85949..9ae313db4c1 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -20,6 +20,7 @@ } .ci-status-icon-pending, +.ci-status-icon-waiting-for-resource, .ci-status-icon-failed-with-warnings, .ci-status-icon-success-with-warnings { svg { diff --git a/app/assets/stylesheets/framework/job_log.scss b/app/assets/stylesheets/framework/job_log.scss index 4a57a458c50..fefc51bf1f7 100644 --- a/app/assets/stylesheets/framework/job_log.scss +++ b/app/assets/stylesheets/framework/job_log.scss @@ -22,6 +22,7 @@ min-width: $job-line-number-width; margin-left: -$job-line-number-margin; padding-right: 1em; + user-select: none; &:hover, &:active, diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index 4aba633e182..738150dbd2e 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -101,6 +101,13 @@ ul.unstyled-list > li { border-bottom: 0; } +ul.list-items-py-2 { + > li { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + } +} + // Generic content list ul.content-list { @include basic-list; diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index bf0f1da6aa3..d54648cc34b 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -271,18 +271,13 @@ } .btn-scroll.animate { - .first-triangle { - animation: blinking-scroll-button 1s ease infinite; - animation-delay: 0.3s; - } - - .second-triangle { - animation: blinking-scroll-button 1s ease infinite; - animation-delay: 0.2s; + .scroll-arrow { + animation: blinking-scroll-button 1.5s ease-in-out infinite; } - .third-triangle { - animation: blinking-scroll-button 1s ease infinite; + .scroll-dot { + animation: blinking-scroll-button 1.5s ease-in-out infinite; + animation-delay: 0.3s; } &:disabled { diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 39e7e4bb7e5..a1bfa03a5ac 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -179,15 +179,15 @@ tbody { background-color: $white-light; + + td { + border-color: $gray-200; + } } tr { th { - border-bottom: solid 2px $gl-gray-200; - } - - td { - border-color: $gl-gray-200; + border-bottom: solid 2px $gray-300; } } diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss index 604b48e11ab..7538459c97b 100644 --- a/app/assets/stylesheets/framework/variables_overrides.scss +++ b/app/assets/stylesheets/framework/variables_overrides.scss @@ -12,6 +12,7 @@ $font-family-sans-serif: $regular-font; $font-family-monospace: $monospace-font; $btn-line-height: 20px; $table-accent-bg: $gray-light; +$table-border-color: $gray-200; $card-border-color: $border-color; $card-cap-bg: $gray-light; $success: $green-500; diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 977fc8329b6..420271c9a1e 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -740,6 +740,7 @@ $ide-commit-header-height: 48px; .ide-sidebar-link { display: flex; align-items: center; + justify-content: center; position: relative; height: 60px; width: 100%; @@ -1076,10 +1077,12 @@ $ide-commit-header-height: 48px; } } -.ide-right-sidebar { +.ide-sidebar { width: auto; min-width: 60px; +} +.ide-right-sidebar { .ide-activity-bar { border-left: 1px solid $white-dark; } diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 90c2e369ccd..31e87d1a7cf 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -133,6 +133,7 @@ .issue-count-badge { border: 0; white-space: nowrap; + padding: 0; } .board-title-text > span, @@ -385,22 +386,19 @@ margin: 5px; } -.issue-boards-sidebar { +.right-sidebar.issue-boards-sidebar { .gutter-toggle { bottom: 15px; width: 22px; - color: $gray-darkest; + padding-left: $gl-padding-32; svg { position: absolute; top: 50%; + right: 0; margin-top: (-11px / 2); - } - - &:hover { - path { - fill: $gray-darkest; - } + height: $gl-font-size-12; + width: $gl-font-size-12; } } diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index c7d51a2093a..0db90fc88fc 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -32,16 +32,12 @@ opacity: 0.2; } - 25% { - opacity: 0.5; - } - 50% { - opacity: 0.7; + opacity: 1; } 100% { - opacity: 1; + opacity: 0.2; } } diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index f394e4ab58a..d1053570093 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -14,9 +14,9 @@ cursor: pointer; @media (min-width: map-get($grid-breakpoints, md)) { - // The `-1` below is to prevent two borders from clashing up against eachother - + // The `+11` is to ensure the file header border shows when scrolled - // the bottom of the compare-versions header and the top of the file header - $mr-file-header-top: $mr-version-controls-height + $header-height + $mr-tabs-height - 1; + $mr-file-header-top: $mr-version-controls-height + $header-height + $mr-tabs-height + 11; position: -webkit-sticky; position: sticky; @@ -552,7 +552,7 @@ table.code { .diff-stats { align-items: center; - padding: 0 0.25rem; + padding: 0 1rem; .diff-stats-group { padding: 0 0.25rem; @@ -564,7 +564,7 @@ table.code { &.is-compare-versions-header { .diff-stats-group { - padding: 0 0.5rem; + padding: 0 0.25rem; } } } @@ -1059,8 +1059,8 @@ table.code { .diff-tree-list { position: -webkit-sticky; position: sticky; - $top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px; - top: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px; + $top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 11px; + top: $header-height + $mr-tabs-height + $mr-version-controls-height + 11px; max-height: calc(100vh - #{$top-pos}); z-index: 202; @@ -1097,10 +1097,7 @@ table.code { .tree-list-scroll { max-height: 100%; - padding-top: $grid-size; padding-bottom: $grid-size; - border-top: 1px solid $border-color; - border-bottom: 1px solid $border-color; overflow-y: scroll; overflow-x: auto; } diff --git a/app/assets/stylesheets/pages/error_details.scss b/app/assets/stylesheets/pages/error_details.scss index dcd25c126c4..61e2df7ea26 100644 --- a/app/assets/stylesheets/pages/error_details.scss +++ b/app/assets/stylesheets/pages/error_details.scss @@ -2,6 +2,11 @@ li { @include gl-line-height-32; } + + .btn-outline-info { + color: $blue-500; + border-color: $blue-500; + } } .stacktrace { diff --git a/app/assets/stylesheets/pages/error_list.scss b/app/assets/stylesheets/pages/error_list.scss new file mode 100644 index 00000000000..f97953ce824 --- /dev/null +++ b/app/assets/stylesheets/pages/error_list.scss @@ -0,0 +1,69 @@ +$gray-border: 1px solid $border-color; + +.error-list { + .sort-control { + .btn { + padding-right: 2rem; + } + + .gl-dropdown-caret { + position: absolute; + right: 0.5rem; + top: 0.5rem; + } + } + + @include media-breakpoint-up(sm) { + .row-top { + border: $gray-border; + background-color: $gray-50; + } + } + + @include media-breakpoint-down(xs) { + .table-row { + border: $gray-border; + border-radius: 4px; + } + + .search-box { + border-top: $gray-border; + border-bottom: $gray-border; + background-color: $gray-50; + } + + .table-col { + min-height: 68px; + + &::before { + text-align: left !important; + } + + &:first-child { + div { + padding: 0 !important; + align-items: flex-end; + } + } + + &:last-child { + height: 64px; + background-color: $gray-normal; + + &::before { + content: none !important; + } + + div { + width: 100% !important; + padding: 0 !important; + + a { + color: $blue-500; + border-color: $blue-500; + } + } + } + } + } +} diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 09b335f9ba2..43636f65eb8 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -173,6 +173,20 @@ margin-top: 7px; } + .gutter-toggle { + margin-left: 20px; + padding-left: 10px; + + &:hover { + color: $gl-text-color; + } + + &:hover, + &:focus { + text-decoration: none; + } + } + .block { @include clearfix; padding: $gl-padding 0; @@ -195,20 +209,6 @@ margin-top: 0; } - .gutter-toggle { - margin-left: 20px; - padding-left: 10px; - - &:hover { - color: $gl-text-color; - } - - &:hover, - &:focus { - text-decoration: none; - } - } - &.assignee { .author-link { display: block; @@ -288,7 +288,7 @@ } .issuable-sidebar { - width: calc(100% + 100px); + width: 100%; height: 100%; overflow-y: scroll; overflow-x: hidden; diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index ae92a2fbd7b..54bca80194f 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -3,7 +3,7 @@ border-bottom: 1px solid $border-color; } -.users-project-form { +.invite-users-form { .btn-success { margin-right: 10px; } diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index c023c9e5cbd..84daec4fb43 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -708,7 +708,7 @@ .mr-version-controls { position: relative; z-index: 203; - background: $gray-light; + background: $white-light; color: $gl-text-color; margin-top: -1px; @@ -732,7 +732,7 @@ } .content-block { - padding: $gl-padding-top $gl-padding; + padding: $gl-padding; border-bottom: 0; } diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 364fe3da71e..82bef91230e 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -795,6 +795,7 @@ } &.ci-status-icon-pending, + &.ci-status-icon-waiting-for-resource, &.ci-status-icon-success-with-warnings { @include mini-pipeline-graph-color($white, $orange-100, $orange-200, $orange-500, $orange-600, $orange-700); } @@ -1092,3 +1093,7 @@ button.mini-pipeline-graph-dropdown-toggle { .progress-bar.bg-primary { background-color: $blue-500 !important; } + +.parent-child-label-container { + padding-top: $gl-padding-4; +} diff --git a/app/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/pages/status.scss index 5d6a4b7cd13..4f3d6fb0d44 100644 --- a/app/assets/stylesheets/pages/status.scss +++ b/app/assets/stylesheets/pages/status.scss @@ -42,6 +42,7 @@ } &.ci-pending, + &.ci-waiting-for-resource, &.ci-failed-with-warnings, &.ci-success-with-warnings { @include status-color($orange-100, $orange-500, $orange-700); diff --git a/app/assets/stylesheets/snippets.scss b/app/assets/stylesheets/snippets.scss index bd777c66b56..0008a0e5c51 100644 --- a/app/assets/stylesheets/snippets.scss +++ b/app/assets/stylesheets/snippets.scss @@ -11,7 +11,7 @@ line-height: $code-line-height; color: $gl-text-color; margin: 20px; - font-weight: 200; + font-weight: $gl-font-weight-normal; .gl-snippet-icon { display: inline-block; @@ -34,7 +34,7 @@ .file-content.code { border: $border-style; - border-radius: 0 0 4px 4px; + border-radius: 0 0 $border-radius-default $border-radius-default; display: flex; box-shadow: none; margin: 0; @@ -45,12 +45,10 @@ overflow-x: auto; pre { + height: 100%; padding: 10px; border: 0; border-radius: 0; - font-family: $monospace-font; - font-size: $code-font-size; - line-height: $code-line-height; margin: 0; overflow: auto; overflow-y: hidden; @@ -58,6 +56,12 @@ word-wrap: normal; border-left: $border-style; } + + code { + font-family: $monospace-font; + font-size: $code-font-size; + line-height: $code-line-height; + } } .line-numbers { @@ -107,17 +111,13 @@ } } - .gitlab-logo { - display: inline-block; - padding-left: 5px; - text-decoration: none; - color: $gl-text-color-secondary; + .gitlab-logo-wrapper { + padding-left: $gl-padding-8; + position: relative; + top: 2px; - .logo-text { - background: image_url('ext_snippet_icons/logo.png') no-repeat left center; - background-size: 18px; - font-weight: $gl-font-weight-normal; - padding-left: 24px; + .gitlab-logo { + height: 18px; } } } @@ -125,7 +125,7 @@ img, .gl-snippet-icon { display: inline-block; - vertical-align: middle; + vertical-align: text-bottom; } } @@ -133,7 +133,7 @@ a.btn { background-color: $white-light; text-decoration: none; - padding: 7px 9px; + padding: 8px 9px; border: $border-style; border-right: 0; @@ -144,11 +144,11 @@ } &:first-child { - border-radius: 3px 0 0 3px; + border-radius: $border-radius-default 0 0 $border-radius-default; } &:last-child { - border-radius: 0 3px 3px 0; + border-radius: 0 $border-radius-default $border-radius-default 0; border-right: $border-style; } } diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 1f4bba5fc33..1517015dda0 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -22,11 +22,20 @@ } } +@each $index, $size in $type-scale { + #{'.lh-#{$index}'} { + line-height: $size; + } +} + .border-width-1px { border-width: 1px; } +.border-bottom-width-1px { border-bottom-width: 1px; } .border-style-dashed { border-style: dashed; } .border-style-solid { border-style: solid; } +.border-bottom-style-solid { border-bottom-style: solid; } .border-color-blue-300 { border-color: $blue-300; } .border-color-default { border-color: $border-color; } +.border-bottom-color-default { border-bottom-color: $border-color; } .box-shadow-default { box-shadow: 0 2px 4px 0 $black-transparent; } .mh-50vh { max-height: 50vh; } diff --git a/app/assets/stylesheets/vendors/atwho.scss b/app/assets/stylesheets/vendors/atwho.scss index ccf3824ea56..37ef52f9573 100644 --- a/app/assets/stylesheets/vendors/atwho.scss +++ b/app/assets/stylesheets/vendors/atwho.scss @@ -23,9 +23,9 @@ } .has-warning { - .name, .description { color: $orange-700; + background-color: $orange-100; } } @@ -59,7 +59,6 @@ &.has-warning { color: $orange-700; - background-color: $orange-100; } } diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 9d81d3fad07..3047ee02680 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -5,11 +5,28 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController before_action :set_application_setting before_action :whitelist_query_limiting, only: [:usage_data] + before_action :validate_self_monitoring_feature_flag_enabled, only: [ + :create_self_monitoring_project, + :status_create_self_monitoring_project, + :delete_self_monitoring_project, + :status_delete_self_monitoring_project + ] + + before_action do + push_frontend_feature_flag(:self_monitoring_project) + end VALID_SETTING_PANELS = %w(general integrations repository ci_cd reporting metrics_and_profiling network preferences).freeze + # The current size of a sidekiq job's jid is 24 characters. The size of the + # jid is an internal detail of Sidekiq, and they do not guarantee that it'll + # stay the same. We chose 50 to give us room in case the size of the jid + # increases. The jid is alphanumeric, so 50 is very generous. There is a spec + # that ensures that the constant value is more than the size of an actual jid. + PARAM_JOB_ID_MAX_SIZE = 50 + VALID_SETTING_PANELS.each do |action| define_method(action) { perform_update if submitted? } end @@ -62,8 +79,103 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController redirect_to ::Gitlab::LetsEncrypt.terms_of_service_url end + def create_self_monitoring_project + job_id = SelfMonitoringProjectCreateWorker.perform_async + + render status: :accepted, json: { + job_id: job_id, + monitor_status: status_create_self_monitoring_project_admin_application_settings_path + } + end + + def status_create_self_monitoring_project + job_id = params[:job_id].to_s + + unless job_id.length <= PARAM_JOB_ID_MAX_SIZE + return render status: :bad_request, json: { + message: _('Parameter "job_id" cannot exceed length of %{job_id_max_size}' % + { job_id_max_size: PARAM_JOB_ID_MAX_SIZE }) + } + end + + if Gitlab::CurrentSettings.instance_administration_project_id.present? + return render status: :ok, json: self_monitoring_data + + elsif SelfMonitoringProjectCreateWorker.in_progress?(job_id) + ::Gitlab::PollingInterval.set_header(response, interval: 3_000) + + return render status: :accepted, json: { + message: _('Job to create self-monitoring project is in progress') + } + end + + render status: :bad_request, json: { + message: _('Self-monitoring project does not exist. Please check logs ' \ + 'for any error messages') + } + end + + def delete_self_monitoring_project + job_id = SelfMonitoringProjectDeleteWorker.perform_async + + render status: :accepted, json: { + job_id: job_id, + monitor_status: status_delete_self_monitoring_project_admin_application_settings_path + } + end + + def status_delete_self_monitoring_project + job_id = params[:job_id].to_s + + unless job_id.length <= PARAM_JOB_ID_MAX_SIZE + return render status: :bad_request, json: { + message: _('Parameter "job_id" cannot exceed length of %{job_id_max_size}' % + { job_id_max_size: PARAM_JOB_ID_MAX_SIZE }) + } + end + + if Gitlab::CurrentSettings.instance_administration_project_id.nil? + return render status: :ok, json: { + message: _('Self-monitoring project has been successfully deleted') + } + + elsif SelfMonitoringProjectDeleteWorker.in_progress?(job_id) + ::Gitlab::PollingInterval.set_header(response, interval: 3_000) + + return render status: :accepted, json: { + message: _('Job to delete self-monitoring project is in progress') + } + end + + render status: :bad_request, json: { + message: _('Self-monitoring project was not deleted. Please check logs ' \ + 'for any error messages') + } + end + private + def validate_self_monitoring_feature_flag_enabled + self_monitoring_project_not_implemented unless Feature.enabled?(:self_monitoring_project) + end + + def self_monitoring_data + { + project_id: Gitlab::CurrentSettings.instance_administration_project_id, + project_full_path: Gitlab::CurrentSettings.instance_administration_project&.full_path + } + end + + def self_monitoring_project_not_implemented + render( + status: :not_implemented, + json: { + message: _('Self-monitoring is not enabled on this GitLab server, contact your administrator.'), + documentation_url: help_page_path('administration/monitoring/gitlab_instance_administration_project/index') + } + ) + end + def set_application_setting @application_setting = ApplicationSetting.current_without_cache end diff --git a/app/controllers/admin/system_info_controller.rb b/app/controllers/admin/system_info_controller.rb index 244fc2b31bb..657aa177ecf 100644 --- a/app/controllers/admin/system_info_controller.rb +++ b/app/controllers/admin/system_info_controller.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true class Admin::SystemInfoController < Admin::ApplicationController - EXCLUDED_MOUNT_OPTIONS = [ - 'nobrowse', - 'read-only', - 'ro' + EXCLUDED_MOUNT_OPTIONS = %w[ + nobrowse + read-only + ro ].freeze EXCLUDED_MOUNT_TYPES = [ diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index f5306801c04..60b5d9b6da8 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -33,6 +33,7 @@ class ApplicationController < ActionController::Base before_action :check_impersonation_availability before_action :required_signup_info + around_action :set_current_context around_action :set_locale around_action :set_session_storage @@ -448,6 +449,14 @@ class ApplicationController < ActionController::Base request.base_url end + def set_current_context(&block) + Gitlab::ApplicationContext.with_context( + user: -> { auth_user }, + project: -> { @project }, + namespace: -> { @group }, + &block) + end + def set_locale(&block) Gitlab::I18n.with_user_locale(current_user, &block) end diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb index 1298b33471b..1d6711e3c22 100644 --- a/app/controllers/boards/issues_controller.rb +++ b/app/controllers/boards/issues_controller.rb @@ -27,17 +27,7 @@ module Boards issues = list_service.execute issues = issues.page(params[:page]).per(params[:per] || 20).without_count Issue.move_nulls_to_end(issues) if Gitlab::Database.read_write? - issues = issues.preload(:milestone, - :assignees, - project: [ - :route, - { - namespace: [:route] - } - ], - labels: [:priorities], - notes: [:award_emoji, :author] - ) + issues = issues.preload(associations_to_preload) render_issues(issues, list_service.metadata) end @@ -74,6 +64,21 @@ module Boards private + def associations_to_preload + [ + :milestone, + :assignees, + project: [ + :route, + { + namespace: [:route] + } + ], + labels: [:priorities], + notes: [:award_emoji, :author] + ] + end + def can_move_issues? head(:forbidden) unless can?(current_user, :admin_issue, board) end @@ -90,7 +95,7 @@ module Boards end def filter_params - params.merge(board_id: params[:board_id], id: params[:list_id]) + params.permit(*Boards::Issues::ListService.valid_params).merge(board_id: params[:board_id], id: params[:list_id]) .reject { |_, value| value.nil? } end @@ -139,3 +144,5 @@ module Boards end end end + +Boards::IssuesController.prepend_if_ee('EE::Boards::IssuesController') diff --git a/app/controllers/clusters/applications_controller.rb b/app/controllers/clusters/applications_controller.rb index be68d0d0a1d..51ddbe2beb4 100644 --- a/app/controllers/clusters/applications_controller.rb +++ b/app/controllers/clusters/applications_controller.rb @@ -47,7 +47,7 @@ class Clusters::ApplicationsController < Clusters::BaseController end def cluster_application_params - params.permit(:application, :hostname, :kibana_hostname, :email, :stack) + params.permit(:application, :hostname, :email, :stack, :modsecurity_enabled) end def cluster_application_destroy_params diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index f4b74b14c0b..52a5f801bad 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -14,7 +14,6 @@ class Clusters::ClustersController < Clusters::BaseController before_action :update_applications_status, only: [:cluster_status] before_action only: [:show] do push_frontend_feature_flag(:enable_cluster_application_elastic_stack) - push_frontend_feature_flag(:enable_cluster_application_crossplane) end helper_method :token_in_session diff --git a/app/controllers/concerns/record_user_last_activity.rb b/app/controllers/concerns/record_user_last_activity.rb index a394ef9a2b8..4013596ba12 100644 --- a/app/controllers/concerns/record_user_last_activity.rb +++ b/app/controllers/concerns/record_user_last_activity.rb @@ -21,7 +21,7 @@ module RecordUserLastActivity return if Gitlab::Database.read_only? if current_user && current_user.last_activity_on != Date.today - Users::ActivityService.new(current_user, "visited #{request.path}").execute + Users::ActivityService.new(current_user).execute end end end diff --git a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb index 2e9905997db..c92b1cecaaa 100644 --- a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb +++ b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb @@ -18,7 +18,7 @@ module RequiresWhitelistedMonitoringClient # debugging purposes return true if Rails.env.development? && request.local? - ip_whitelist.any? { |e| e.include?(Gitlab::RequestContext.client_ip) } + ip_whitelist.any? { |e| e.include?(Gitlab::RequestContext.instance.client_ip) } end def ip_whitelist diff --git a/app/controllers/concerns/sourcegraph_gon.rb b/app/controllers/concerns/sourcegraph_decorator.rb index 01925cf9d4d..5ef09b9221f 100644 --- a/app/controllers/concerns/sourcegraph_gon.rb +++ b/app/controllers/concerns/sourcegraph_decorator.rb @@ -1,10 +1,19 @@ # frozen_string_literal: true -module SourcegraphGon +module SourcegraphDecorator extend ActiveSupport::Concern included do before_action :push_sourcegraph_gon, if: :html_request? + + content_security_policy do |p| + next if p.directives.blank? + next unless Gitlab::CurrentSettings.sourcegraph_enabled + + default_connect_src = p.directives['connect-src'] || p.directives['default-src'] + connect_src_values = Array.wrap(default_connect_src) | [Gitlab::CurrentSettings.sourcegraph_url] + p.connect_src(*connect_src_values) + end end private diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb index a8ffa33f1c7..9ec8f930a78 100644 --- a/app/controllers/concerns/spammable_actions.rb +++ b/app/controllers/concerns/spammable_actions.rb @@ -11,7 +11,7 @@ module SpammableActions end def mark_as_spam - if SpamService.new(spammable).mark_as_spam! + if Spam::MarkAsSpamService.new(spammable: spammable).execute redirect_to spammable_path, notice: _("%{spammable_titlecase} was submitted to Akismet successfully.") % { spammable_titlecase: spammable.spammable_entity_type.titlecase } else redirect_to spammable_path, alert: _('Error with Akismet. Please check the logs for more info.') diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index 72d40f709e6..d7ff2ded5ae 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -3,6 +3,7 @@ class GraphqlController < ApplicationController # Unauthenticated users have access to the API for public data skip_before_action :authenticate_user! + skip_around_action :set_session_storage # Allow missing CSRF tokens, this would mean that if a CSRF is invalid or missing, # the user won't be authenticated but can proceed as an anonymous user. diff --git a/app/controllers/groups/group_links_controller.rb b/app/controllers/groups/group_links_controller.rb index 7965311c5f1..d3360acd245 100644 --- a/app/controllers/groups/group_links_controller.rb +++ b/app/controllers/groups/group_links_controller.rb @@ -3,6 +3,7 @@ class Groups::GroupLinksController < Groups::ApplicationController before_action :check_feature_flag! before_action :authorize_admin_group! + before_action :group_link, only: [:update, :destroy] def create shared_with_group = Group.find(params[:shared_with_group_id]) if params[:shared_with_group_id].present? @@ -22,13 +23,36 @@ class Groups::GroupLinksController < Groups::ApplicationController redirect_to group_group_members_path(group) end + def update + @group_link.update(group_link_params) + end + + def destroy + Groups::GroupLinks::DestroyService.new(nil, nil).execute(@group_link) + + respond_to do |format| + format.html do + redirect_to group_group_members_path(group), status: :found + end + format.js { head :ok } + end + end + private + def group_link + @group_link ||= group.shared_with_group_links.find(params[:id]) + end + def group_link_create_params params.permit(:shared_group_access, :expires_at) end + def group_link_params + params.require(:group_link).permit(:group_access, :expires_at) + end + def check_feature_flag! - render_404 unless Feature.enabled?(:share_group_with_group) + render_404 unless Feature.enabled?(:share_group_with_group, default_enabled: true) end end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index dcdf9aced1a..d1eed85fde6 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -20,28 +20,17 @@ class Groups::GroupMembersController < Groups::ApplicationController :override def index - can_manage_members = can?(current_user, :admin_group_member, @group) - @sort = params[:sort].presence || sort_value_name @project = @group.projects.find(params[:project_id]) if params[:project_id] @members = find_members if can_manage_members - @invited_members = @members.invite - @invited_members = @invited_members.search_invite_email(params[:search_invited]) if params[:search_invited].present? - @invited_members = present_members(@invited_members.page(params[:invited_members_page]).per(MEMBER_PER_PAGE_LIMIT)) + @skip_groups = @group.related_group_ids + @invited_members = present_invited_members(@members) end @members = @members.non_invite - @members = @members.search(params[:search]) if params[:search].present? - @members = @members.sort_by_attribute(@sort) - - if can_manage_members && params[:two_factor].present? - @members = @members.filter_by_2fa(params[:two_factor]) - end - - @members = @members.page(params[:page]).per(MEMBER_PER_PAGE_LIMIT) - @members = present_members(@members) + @members = present_group_members(@members) @requesters = present_members( AccessRequestsFinder.new(@group).execute(current_user)) @@ -54,8 +43,30 @@ class Groups::GroupMembersController < Groups::ApplicationController private + def present_invited_members(members) + invited_members = members.invite + + if params[:search_invited].present? + invited_members = invited_members.search_invite_email(params[:search_invited]) + end + + present_members(invited_members + .page(params[:invited_members_page]) + .per(MEMBER_PER_PAGE_LIMIT)) + end + def find_members - GroupMembersFinder.new(@group).execute(include_relations: requested_relations) + filter_params = params.slice(:two_factor, :search).merge(sort: @sort) + GroupMembersFinder.new(@group, current_user).execute(include_relations: requested_relations, params: filter_params) + end + + def can_manage_members + can?(current_user, :admin_group_member, @group) + end + + def present_group_members(original_members) + members = original_members.page(params[:page]).per(MEMBER_PER_PAGE_LIMIT) + present_members(members) end end diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index 1e9d51cf970..7eba73daa3c 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -119,7 +119,9 @@ class Groups::MilestonesController < Groups::ApplicationController end def search_params - params.permit(:state, :search_title).merge(group_ids: group.id) + groups = request.format.json? ? group.self_and_ancestors.select(:id) : group.id + + params.permit(:state, :search_title).merge(group_ids: groups) end end diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb index 4c9aac9a327..ca35b07111c 100644 --- a/app/controllers/ide_controller.rb +++ b/app/controllers/ide_controller.rb @@ -3,6 +3,10 @@ class IdeController < ApplicationController layout 'fullscreen' + before_action do + push_frontend_feature_flag(:stage_all_by_default, default_enabled: true) + end + def index Gitlab::UsageDataCounters::WebIdeCounter.increment_views_count end diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 92f36c031f1..bc3308fd6c6 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -31,7 +31,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController # Extend the standard message generation to accept our custom exception def failure_message exception = request.env["omniauth.error"] - error = exception.error_reason if exception.respond_to?(:error_reason) + error = exception.error_reason if exception.respond_to?(:error_reason) error ||= exception.error if exception.respond_to?(:error) error ||= exception.message if exception.respond_to?(:message) error ||= request.env["omniauth.error.type"].to_s @@ -177,7 +177,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController message << _("Create a GitLab account first, and then connect it to your %{label} account.") % { label: label } end - flash[:notice] = message.join(' ') + flash[:alert] = message.join(' ') redirect_to new_user_session_path end diff --git a/app/controllers/profiles/active_sessions_controller.rb b/app/controllers/profiles/active_sessions_controller.rb index c473023cacb..f409193aefc 100644 --- a/app/controllers/profiles/active_sessions_controller.rb +++ b/app/controllers/profiles/active_sessions_controller.rb @@ -4,4 +4,13 @@ class Profiles::ActiveSessionsController < Profiles::ApplicationController def index @sessions = ActiveSession.list(current_user).reject(&:is_impersonated) end + + def destroy + ActiveSession.destroy_with_public_id(current_user, params[:id]) + + respond_to do |format| + format.html { redirect_to profile_active_sessions_url, status: :found } + format.js { head :ok } + end + end end diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index 214640a5295..2166dd7dad7 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -48,7 +48,8 @@ class Profiles::PreferencesController < Profiles::ApplicationController :time_display_relative, :time_format_in_24h, :show_whitespace_in_diffs, - :sourcegraph_enabled + :sourcegraph_enabled, + :render_whitespace_in_code ] end end diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb index 92655d593dd..b62ce940e9c 100644 --- a/app/controllers/projects/blame_controller.rb +++ b/app/controllers/projects/blame_controller.rb @@ -17,6 +17,7 @@ class Projects::BlameController < Projects::ApplicationController end environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } + environment_params[:find_latest] = true @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last @blame_groups = Gitlab::Blame.new(@blob, @commit).groups diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 7c97f771a70..3cd14cf845f 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -8,7 +8,7 @@ class Projects::BlobController < Projects::ApplicationController include NotesHelper include ActionView::Helpers::SanitizeHelper include RedirectsForMissingPathOnTree - include SourcegraphGon + include SourcegraphDecorator prepend_before_action :authenticate_user!, only: [:edit] @@ -205,6 +205,7 @@ class Projects::BlobController < Projects::ApplicationController def show_html environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } + environment_params[:find_latest] = true @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last @last_commit = @repository.last_commit_for_path(@commit.id, @blob.path) diff --git a/app/controllers/projects/ci/lints_controller.rb b/app/controllers/projects/ci/lints_controller.rb index 812420e9708..b50afa12da0 100644 --- a/app/controllers/projects/ci/lints_controller.rb +++ b/app/controllers/projects/ci/lints_controller.rb @@ -10,8 +10,8 @@ class Projects::Ci::LintsController < Projects::ApplicationController @content = params[:content] result = Gitlab::Ci::YamlProcessor.new_with_validation_errors(@content, yaml_processor_options) - @error = result.errors.join(', ') - @status = result.valid? + @status = result.valid? + @errors = result.errors if result.valid? @config_processor = result.content diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index afb670b687b..3f2dc9b09fa 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -8,7 +8,7 @@ class Projects::CommitController < Projects::ApplicationController include CreatesCommit include DiffForPath include DiffHelper - include SourcegraphGon + include SourcegraphDecorator # Authorize before_action :require_non_empty_project @@ -151,7 +151,7 @@ class Projects::CommitController < Projects::ApplicationController @diffs = commit.diffs(opts) @notes_count = commit.notes.count - @environment = EnvironmentsFinder.new(@project, current_user, commit: @commit).execute.last + @environment = EnvironmentsFinder.new(@project, current_user, commit: @commit, find_latest: true).execute.last end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb index 5586c2fc631..943277afe95 100644 --- a/app/controllers/projects/compare_controller.rb +++ b/app/controllers/projects/compare_controller.rb @@ -101,6 +101,7 @@ class Projects::CompareController < Projects::ApplicationController def define_environment if compare environment_params = @repository.branch_exists?(head_ref) ? { ref: head_ref } : { commit: compare.commit } + environment_params[:find_latest] = true @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last end end diff --git a/app/controllers/projects/environments/sample_metrics_controller.rb b/app/controllers/projects/environments/sample_metrics_controller.rb index 79a7eab150b..9176c7cbd56 100644 --- a/app/controllers/projects/environments/sample_metrics_controller.rb +++ b/app/controllers/projects/environments/sample_metrics_controller.rb @@ -2,7 +2,7 @@ class Projects::Environments::SampleMetricsController < Projects::ApplicationController def query - result = Metrics::SampleMetricsService.new(params[:identifier]).query + result = Metrics::SampleMetricsService.new(params[:identifier], range_start: params[:start], range_end: params[:end]).query if result render json: { "status": "success", "data": { "resultType": "matrix", "result": result } } diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 1179782036d..70c4b536854 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -15,11 +15,15 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do push_frontend_feature_flag(:prometheus_computed_alerts) end + before_action do + push_frontend_feature_flag(:auto_stop_environments) + end after_action :expire_etag_cache, only: [:cancel_auto_stop] def index @environments = project.environments .with_state(params[:scope] || :available) + @project = ProjectPresenter.new(project, current_user: current_user) respond_to do |format| format.html @@ -28,6 +32,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController render json: { environments: serialize_environments(request, response, params[:nested]), + review_app: serialize_review_app, available_count: project.environments.available.count, stopped_count: project.environments.stopped.count } @@ -217,7 +222,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController def metrics_dashboard_params params - .permit(:embedded, :group, :title, :y_label, :dashboard_path, :environment) + .permit(:embedded, :group, :title, :y_label, :dashboard_path, :environment, :sample_metrics) .merge(dashboard_path: params[:dashboard], environment: environment) end @@ -239,6 +244,10 @@ class Projects::EnvironmentsController < Projects::ApplicationController .represent(@environments) end + def serialize_review_app + ReviewAppSetupSerializer.new(current_user: @current_user).represent(@project) + end + def authorize_stop_environment! access_denied! unless can?(current_user, :stop_environment, environment) end diff --git a/app/controllers/projects/error_tracking/base_controller.rb b/app/controllers/projects/error_tracking/base_controller.rb new file mode 100644 index 00000000000..6efc6d00702 --- /dev/null +++ b/app/controllers/projects/error_tracking/base_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Projects::ErrorTracking::BaseController < Projects::ApplicationController + POLLING_INTERVAL = 1_000 + + def set_polling_interval + Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) + end +end diff --git a/app/controllers/projects/error_tracking/projects_controller.rb b/app/controllers/projects/error_tracking/projects_controller.rb new file mode 100644 index 00000000000..75a2c976d8b --- /dev/null +++ b/app/controllers/projects/error_tracking/projects_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Projects + module ErrorTracking + class ProjectsController < Projects::ApplicationController + respond_to :json + + before_action :authorize_read_sentry_issue! + + def index + service = ::ErrorTracking::ListProjectsService.new( + project, + current_user, + list_projects_params + ) + result = service.execute + + if result[:status] == :success + render json: { projects: serialize_projects(result[:projects]) } + else + render( + status: result[:http_status] || :bad_request, + json: { message: result[:message] } + ) + end + end + + private + + def list_projects_params + { api_host: params[:api_host], token: params[:token] } + end + + def serialize_projects(projects) + ::ErrorTracking::ProjectSerializer + .new(project: project, user: current_user) + .represent(projects) + end + end + end +end diff --git a/app/controllers/projects/error_tracking/stack_traces_controller.rb b/app/controllers/projects/error_tracking/stack_traces_controller.rb new file mode 100644 index 00000000000..c5d5d6da6a6 --- /dev/null +++ b/app/controllers/projects/error_tracking/stack_traces_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Projects + module ErrorTracking + class StackTracesController < Projects::ErrorTracking::BaseController + respond_to :json + + before_action :authorize_read_sentry_issue!, :set_polling_interval + + def index + result = fetch_latest_event_issue + + if result[:status] == :success + result_with_syntax_highlight = Gitlab::ErrorTracking::StackTraceHighlightDecorator.decorate(result[:latest_event]) + + render json: { error: serialize_error_event(result_with_syntax_highlight) } + else + render json: { message: result[:message] }, status: result.fetch(:http_status, :bad_request) + end + end + + private + + def fetch_latest_event_issue + ::ErrorTracking::IssueLatestEventService + .new(project, current_user, issue_id: params[:issue_id]) + .execute + end + + def serialize_error_event(event) + ::ErrorTracking::ErrorEventSerializer + .new(project: project, user: current_user) + .represent(event) + end + end + end +end diff --git a/app/controllers/projects/error_tracking_controller.rb b/app/controllers/projects/error_tracking_controller.rb index ba21ccfb169..88f739ce29e 100644 --- a/app/controllers/projects/error_tracking_controller.rb +++ b/app/controllers/projects/error_tracking_controller.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -class Projects::ErrorTrackingController < Projects::ApplicationController - before_action :authorize_read_sentry_issue! - before_action :set_issue_id, only: [:details, :stack_trace] +class Projects::ErrorTrackingController < Projects::ErrorTracking::BaseController + respond_to :json - POLLING_INTERVAL = 10_000 + before_action :authorize_read_sentry_issue! + before_action :set_issue_id, only: :details def index respond_to do |format| @@ -20,25 +20,21 @@ class Projects::ErrorTrackingController < Projects::ApplicationController respond_to do |format| format.html format.json do + set_polling_interval render_issue_detail_json end end end - def stack_trace - respond_to do |format| - format.json do - render_issue_stack_trace_json - end - end - end + def update + service = ErrorTracking::IssueUpdateService.new(project, current_user, issue_update_params) + result = service.execute - def list_projects - respond_to do |format| - format.json do - render_project_list_json - end - end + return if handle_errors(result) + + render json: { + result: result + } end private @@ -71,41 +67,6 @@ class Projects::ErrorTrackingController < Projects::ApplicationController } end - def render_issue_stack_trace_json - service = ErrorTracking::IssueLatestEventService.new(project, current_user, issue_details_params) - result = service.execute - - return if handle_errors(result) - - result_with_syntax_highlight = Gitlab::ErrorTracking::StackTraceHighlightDecorator.decorate(result[:latest_event]) - - render json: { - error: serialize_error_event(result_with_syntax_highlight) - } - end - - def render_project_list_json - service = ErrorTracking::ListProjectsService.new( - project, - current_user, - list_projects_params - ) - result = service.execute - - if result[:status] == :success - render json: { - projects: serialize_projects(result[:projects]) - } - else - return render( - status: result[:http_status] || :bad_request, - json: { - message: result[:message] - } - ) - end - end - def handle_errors(result) unless result[:status] == :success render json: { message: result[:message] }, @@ -117,8 +78,8 @@ class Projects::ErrorTrackingController < Projects::ApplicationController params.permit(:search_term, :sort, :cursor) end - def list_projects_params - params.require(:error_tracking_setting).permit([:api_host, :token]) + def issue_update_params + params.permit(:issue_id, :status) end def issue_details_params @@ -129,10 +90,6 @@ class Projects::ErrorTrackingController < Projects::ApplicationController @issue_id = issue_details_params[:issue_id] end - def set_polling_interval - Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) - end - def serialize_errors(errors) ErrorTracking::ErrorSerializer .new(project: project, user: current_user) @@ -144,16 +101,4 @@ class Projects::ErrorTrackingController < Projects::ApplicationController .new(project: project, user: current_user) .represent(error) end - - def serialize_error_event(event) - ErrorTracking::ErrorEventSerializer - .new(project: project, user: current_user) - .represent(event) - end - - def serialize_projects(projects) - ErrorTracking::ProjectSerializer - .new(project: project, user: current_user) - .represent(projects) - end end diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index cb6d9c2ba18..9806b91c7e8 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -9,6 +9,7 @@ class Projects::ForksController < Projects::ApplicationController before_action :require_non_empty_project before_action :authorize_download_code! before_action :authenticate_user!, only: [:new, :create] + before_action :authorize_fork_project!, only: [:new, :create] # rubocop: disable CodeReuse/ActiveRecord def index @@ -61,6 +62,8 @@ class Projects::ForksController < Projects::ApplicationController end # rubocop: enable CodeReuse/ActiveRecord + private + def whitelist_query_limiting Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42335') end diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb index ccfc38d97b2..3f6e116a62b 100644 --- a/app/controllers/projects/git_http_client_controller.rb +++ b/app/controllers/projects/git_http_client_controller.rb @@ -3,6 +3,7 @@ class Projects::GitHttpClientController < Projects::ApplicationController include ActionController::HttpAuthentication::Basic include KerberosSpnegoHelper + include Gitlab::Utils::StrongMemoize attr_reader :authentication_result, :redirected_path @@ -47,7 +48,7 @@ class Projects::GitHttpClientController < Projects::ApplicationController send_final_spnego_response return # Allow access end - elsif project && download_request? && http_allowed? && Guest.can?(:download_code, project) + elsif http_download_allowed? @authentication_result = Gitlab::Auth::Result.new(nil, project, :none, [:download_code]) @@ -89,11 +90,9 @@ class Projects::GitHttpClientController < Projects::ApplicationController end def repository - repo_type.repository_for(project) - end - - def wiki? - repo_type.wiki? + strong_memoize(:repository) do + repo_type.repository_for(project) + end end def repo_type @@ -113,8 +112,10 @@ class Projects::GitHttpClientController < Projects::ApplicationController authentication_result.ci?(project) end - def http_allowed? - Gitlab::ProtocolAccess.allowed?('http') + def http_download_allowed? + Gitlab::ProtocolAccess.allowed?('http') && + download_request? && + project && Guest.can?(:download_code, project) end end diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb index 93f7ce73a51..236f1b967de 100644 --- a/app/controllers/projects/git_http_controller.rb +++ b/app/controllers/projects/git_http_controller.rb @@ -75,17 +75,20 @@ class Projects::GitHttpController < Projects::GitHttpClientController end def enqueue_fetch_statistics_update - return if wiki? - return unless project.daily_statistics_enabled? + return if Gitlab::Database.read_only? + return if repo_type.wiki? + return unless project&.daily_statistics_enabled? ProjectDailyStatisticsWorker.perform_async(project.id) end def access - @access ||= access_klass.new(access_actor, project, - 'http', authentication_abilities: authentication_abilities, - namespace_path: params[:namespace_id], project_path: project_path, - redirected_path: redirected_path, auth_result_type: auth_result_type) + @access ||= access_klass.new(access_actor, project, 'http', + authentication_abilities: authentication_abilities, + namespace_path: params[:namespace_id], + project_path: project_path, + redirected_path: redirected_path, + auth_result_type: auth_result_type) end def access_actor @@ -107,7 +110,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController end def log_user_activity - Users::ActivityService.new(user, 'pull').execute + Users::ActivityService.new(user).execute end end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 229374c3929..0944d7b47bf 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -44,9 +44,11 @@ class Projects::IssuesController < Projects::ApplicationController before_action do push_frontend_feature_flag(:vue_issuable_sidebar, project.group) - push_frontend_feature_flag(:release_search_filter, project, default_enabled: true) + push_frontend_feature_flag(:issue_link_types, project) end + around_action :allow_gitaly_ref_name_caching, only: [:discussions] + respond_to :html alias_method :designs, :show diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 796f3ff603f..cb473d6ee96 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -51,6 +51,8 @@ class Projects::JobsController < Projects::ApplicationController build.trace.read do |stream| respond_to do |format| format.json do + build.trace.being_watched! + # TODO: when the feature flag is removed we should not pass # content_format to serialize method. content_format = Feature.enabled?(:job_log_json, @project, default_enabled: true) ? :json : :html diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index 78dc196b08e..23222cbd37c 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -12,10 +12,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap before_action :build_merge_request, except: [:create] def new - # n+1: https://gitlab.com/gitlab-org/gitlab-foss/issues/40934 - Gitlab::GitalyClient.allow_n_plus_1_calls do - define_new_vars - end + define_new_vars end def create @@ -52,7 +49,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap @diff_notes_disabled = true - @environment = @merge_request.environments_for(current_user).last + @environment = @merge_request.environments_for(current_user, latest: true).last render json: { html: view_to_html_string('projects/merge_requests/creations/_diffs', diffs: @diffs, environment: @environment) } end diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 37d90ecdc00..c0c8474232a 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -51,7 +51,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic # Deprecated: https://gitlab.com/gitlab-org/gitlab/issues/37735 def render_diffs diffs = @compare.diffs(diff_options) - @environment = @merge_request.environments_for(current_user).last + @environment = @merge_request.environments_for(current_user, latest: true).last diffs.unfold_diff_files(note_positions.unfoldable) diffs.write_cache diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 69e3e7c7acb..17025670488 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -9,7 +9,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo include ToggleAwardEmoji include IssuableCollections include RecordUserLastActivity - include SourcegraphGon + include SourcegraphDecorator skip_before_action :merge_request, only: [:index, :bulk_update] before_action :whitelist_query_limiting, only: [:assign_related_issues, :update] @@ -25,7 +25,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action do push_frontend_feature_flag(:vue_issuable_sidebar, @project.group) - push_frontend_feature_flag(:release_search_filter, @project, default_enabled: true) push_frontend_feature_flag(:async_mr_widget, @project) end @@ -222,11 +221,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo def ci_environments_status environments = if ci_environments_status_on_merge_result? - if Feature.enabled?(:deployment_merge_requests_widget, @project) - EnvironmentStatus.for_deployed_merge_request(@merge_request, current_user) - else - EnvironmentStatus.after_merge_request(@merge_request, current_user) - end + EnvironmentStatus.for_deployed_merge_request(@merge_request, current_user) else EnvironmentStatus.for_merge_request(@merge_request, current_user) end diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb index f1e591ea1ec..18a171700e9 100644 --- a/app/controllers/projects/pages_controller.rb +++ b/app/controllers/projects/pages_controller.rb @@ -34,7 +34,7 @@ class Projects::PagesController < Projects::ApplicationController if result[:status] == :success flash[:notice] = 'Your changes have been saved' else - flash[:alert] = 'Something went wrong on our end' + flash[:alert] = result[:message] end redirect_to project_pages_path(@project) @@ -45,6 +45,12 @@ class Projects::PagesController < Projects::ApplicationController private def project_params - params.require(:project).permit(:pages_https_only) + params.require(:project).permit(project_params_attributes) + end + + def project_params_attributes + %i[pages_https_only] end end + +Projects::PagesController.prepend_if_ee('EE::Projects::PagesController') diff --git a/app/controllers/projects/performance_monitoring/dashboards_controller.rb b/app/controllers/projects/performance_monitoring/dashboards_controller.rb new file mode 100644 index 00000000000..2d872b78096 --- /dev/null +++ b/app/controllers/projects/performance_monitoring/dashboards_controller.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Projects + module PerformanceMonitoring + class DashboardsController < ::Projects::ApplicationController + include BlobHelper + + before_action :check_repository_available! + before_action :validate_required_params! + + rescue_from ActionController::ParameterMissing do |exception| + respond_error(http_status: :bad_request, message: _('Request parameter %{param} is missing.') % { param: exception.param }) + end + + def create + result = ::Metrics::Dashboard::CloneDashboardService.new(project, current_user, dashboard_params).execute + + if result[:status] == :success + respond_success(result) + else + respond_error(result) + end + end + + private + + def respond_success(result) + set_web_ide_link_notice(result.dig(:dashboard, :path)) + respond_to do |format| + format.json { render status: result.delete(:http_status), json: result } + end + end + + def respond_error(result) + respond_to do |format| + format.json { render json: { error: result[:message] }, status: result[:http_status] } + end + end + + def set_web_ide_link_notice(new_dashboard_path) + web_ide_link_start = "<a href=\"#{ide_edit_path(project, redirect_safe_branch_name, new_dashboard_path)}\">" + message = _("Your dashboard has been copied. You can %{web_ide_link_start}edit it here%{web_ide_link_end}.") % { web_ide_link_start: web_ide_link_start, web_ide_link_end: "</a>" } + flash[:notice] = message.html_safe + end + + def validate_required_params! + params.require(%i(branch file_name dashboard commit_message)) + end + + def redirect_safe_branch_name + repository.find_branch(params[:branch]).name + end + + def dashboard_params + params.permit(%i(branch file_name dashboard commit_message)).to_h + end + end + end +end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index e3ef8f3f2ff..a62eb94a3e4 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -80,6 +80,12 @@ class Projects::PipelinesController < Projects::ApplicationController end end + def destroy + ::Ci::DestroyPipelineService.new(project, current_user).execute(pipeline) + + redirect_to project_pipelines_path(project), status: :see_other + end + def builds render_show end diff --git a/app/controllers/projects/prometheus/metrics_controller.rb b/app/controllers/projects/prometheus/metrics_controller.rb index 267dca74f96..c9c7ba1253f 100644 --- a/app/controllers/projects/prometheus/metrics_controller.rb +++ b/app/controllers/projects/prometheus/metrics_controller.rb @@ -23,7 +23,7 @@ module Projects private def prometheus_adapter - @prometheus_adapter ||= ::Prometheus::AdapterService.new(project).prometheus_adapter + @prometheus_adapter ||= ::Gitlab::Prometheus::Adapter.new(project, project.deployment_platform&.cluster).prometheus_adapter end def require_prometheus_metrics! diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb index f39d98be516..f7bc6898112 100644 --- a/app/controllers/projects/raw_controller.rb +++ b/app/controllers/projects/raw_controller.rb @@ -9,9 +9,9 @@ class Projects::RawController < Projects::ApplicationController prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:blob) } before_action :require_non_empty_project - before_action :assign_ref_vars before_action :authorize_download_code! before_action :show_rate_limit, only: [:show], unless: :external_storage_request? + before_action :assign_ref_vars before_action :redirect_to_external_storage, only: :show, if: :static_objects_external_storage_enabled? def show @@ -23,11 +23,15 @@ class Projects::RawController < Projects::ApplicationController private def show_rate_limit - if rate_limiter.throttled?(:show_raw_controller, scope: [@project, @commit, @path], threshold: raw_blob_request_limit) + # This bypasses assign_ref_vars to avoid a Gitaly FindCommit lookup. + # When rate limiting, we really don't care if a different commit is + # being requested. + _ref, path = extract_ref(get_id) + + if rate_limiter.throttled?(:show_raw_controller, scope: [@project, path], threshold: raw_blob_request_limit) rate_limiter.log_request(request, :raw_blob_request_limit, current_user) - flash[:alert] = _('You cannot access the raw file. Please wait a minute.') - redirect_to project_blob_path(@project, File.join(@ref, @path)), status: :too_many_requests + render plain: _('You cannot access the raw file. Please wait a minute.'), status: :too_many_requests end end diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index d6030a9e455..08a57a9b146 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -7,7 +7,7 @@ class Projects::ReleasesController < Projects::ApplicationController before_action :authorize_read_release! before_action do push_frontend_feature_flag(:release_issue_summary, project) - push_frontend_feature_flag(:release_evidence_collection, project) + push_frontend_feature_flag(:release_evidence_collection, project, default_enabled: true) end before_action :authorize_update_release!, only: %i[edit update] before_action :authorize_read_release_evidence!, only: [:evidence] diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb index dbd11c8ddc8..daddd9dd485 100644 --- a/app/controllers/projects/snippets_controller.rb +++ b/app/controllers/projects/snippets_controller.rb @@ -46,8 +46,8 @@ class Projects::SnippetsController < Projects::ApplicationController def create create_params = snippet_params.merge(spammable_params) - - @snippet = CreateSnippetService.new(@project, current_user, create_params).execute + service_response = Snippets::CreateService.new(project, current_user, create_params).execute + @snippet = service_response.payload[:snippet] recaptcha_check_with_fallback { render :new } end @@ -55,7 +55,8 @@ class Projects::SnippetsController < Projects::ApplicationController def update update_params = snippet_params.merge(spammable_params) - UpdateSnippetService.new(project, current_user, @snippet, update_params).execute + service_response = Snippets::UpdateService.new(project, current_user, update_params).execute(@snippet) + @snippet = service_response.payload[:snippet] recaptcha_check_with_fallback { render :edit } end @@ -89,11 +90,17 @@ class Projects::SnippetsController < Projects::ApplicationController end def destroy - return access_denied! unless can?(current_user, :admin_project_snippet, @snippet) - - @snippet.destroy - - redirect_to project_snippets_path(@project), status: :found + service_response = Snippets::DestroyService.new(current_user, @snippet).execute + + if service_response.success? + redirect_to project_snippets_path(project), status: :found + elsif service_response.http_status == 403 + access_denied! + else + redirect_to project_snippet_path(project, @snippet), + status: :found, + alert: service_response.message + end end protected diff --git a/app/controllers/projects/starrers_controller.rb b/app/controllers/projects/starrers_controller.rb index 4efe956e973..d9654f4f72a 100644 --- a/app/controllers/projects/starrers_controller.rb +++ b/app/controllers/projects/starrers_controller.rb @@ -7,8 +7,8 @@ class Projects::StarrersController < Projects::ApplicationController @starrers = UsersStarProjectsFinder.new(@project, params, current_user: @current_user).execute @sort = params[:sort].presence || sort_value_name @starrers = @starrers.preload_users.sort_by_attribute(@sort).page(params[:page]) - @public_count = @project.starrers.with_public_profile.size - @total_count = @project.starrers.size + @public_count = @project.starrers.with_public_profile.size + @total_count = @project.starrers.size @private_count = @total_count - @public_count end diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index eec89afe354..aba28e5c835 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -15,6 +15,10 @@ class Projects::TreeController < Projects::ApplicationController before_action :authorize_download_code! before_action :authorize_edit_tree!, only: [:create_dir] + before_action only: [:show] do + push_frontend_feature_flag(:vue_file_list_lfs_badge) + end + def show return render_404 unless @repository.commit(@ref) @@ -28,7 +32,8 @@ class Projects::TreeController < Projects::ApplicationController respond_to do |format| format.html do - lfs_blob_ids + lfs_blob_ids if Feature.disabled?(:vue_file_list, @project) + @last_commit = @repository.last_commit_for_path(@commit.id, @tree.path) || @commit end end diff --git a/app/controllers/projects/uploads_controller.rb b/app/controllers/projects/uploads_controller.rb index 3e5a1cfc74d..72251988b5e 100644 --- a/app/controllers/projects/uploads_controller.rb +++ b/app/controllers/projects/uploads_controller.rb @@ -29,4 +29,14 @@ class Projects::UploadsController < Projects::ApplicationController Project.find_by_full_path("#{namespace}/#{id}") end + + # Overrides ApplicationController#build_canonical_path since there are + # multiple routes that match project uploads: + # https://gitlab.com/gitlab-org/gitlab/issues/196396 + def build_canonical_path(project) + return super unless action_name == 'show' + return super unless params[:secret] && params[:filename] + + show_namespace_project_uploads_url(project.namespace.to_param, project.to_param, params[:secret], params[:filename]) + end end diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index fb06299676c..cfc0925d9e1 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -39,6 +39,10 @@ class Projects::WikisController < Projects::ApplicationController if @page set_encoding_error unless valid_encoding? + # Assign vars expected by MarkupHelper + @ref = params[:version_id] + @path = @page.path + render 'show' elsif file_blob send_blob(@project_wiki.repository, file_blob) diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 47d6fb67108..bf05defbc2e 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -21,8 +21,7 @@ class ProjectsController < Projects::ApplicationController before_action :assign_ref_vars, if: -> { action_name == 'show' && repo_exists? } before_action :tree, if: -> { action_name == 'show' && repo_exists? && project_view_files? } - before_action :lfs_blob_ids, - if: -> { action_name == 'show' && repo_exists? && project_view_files? } + before_action :lfs_blob_ids, if: :show_blob_ids?, only: :show before_action :project_export_enabled, only: [:export, :download_export, :remove_export, :generate_new_export] before_action :present_project, only: [:edit] before_action :authorize_download_code!, only: [:refs] @@ -52,7 +51,7 @@ class ProjectsController < Projects::ApplicationController def edit @badge_api_endpoint = expose_url(api_v4_projects_badges_path(id: @project.id)) - render 'edit' + render_edit end def create @@ -86,7 +85,7 @@ class ProjectsController < Projects::ApplicationController else flash.now[:alert] = result[:message] - format.html { render 'edit' } + format.html { render_edit } end format.js @@ -296,6 +295,10 @@ class ProjectsController < Projects::ApplicationController private + def show_blob_ids? + repo_exists? && project_view_files? && Feature.disabled?(:vue_file_list, @project) + end + # Render project landing depending of which features are available # So if page is not available in the list it renders the next page # @@ -383,10 +386,12 @@ class ProjectsController < Projects::ApplicationController :template_project_id, :merge_method, :initialize_with_readme, + :autoclose_referenced_issues, project_feature_attributes: %i[ builds_access_level issues_access_level + forking_access_level merge_requests_access_level repository_access_level snippets_access_level @@ -483,6 +488,10 @@ class ProjectsController < Projects::ApplicationController def rate_limiter ::Gitlab::ApplicationRateLimiter end + + def render_edit + render 'edit' + end end ProjectsController.prepend_if_ee('EE::ProjectsController') diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 5fc7f5c84f0..c0ba87bf3ed 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -60,7 +60,7 @@ class RegistrationsController < Devise::RegistrationsController end def update_registration - user_params = params.require(:user).permit(:name, :role, :setup_for_company) + user_params = params.require(:user).permit(:role, :setup_for_company) result = ::Users::SignupService.new(current_user, user_params).execute if result[:status] == :success @@ -152,13 +152,7 @@ class RegistrationsController < Devise::RegistrationsController end def sign_up_params - clean_params = params.require(:user).permit(:username, :email, :email_confirmation, :name, :password) - - if experiment_enabled?(:signup_flow) - clean_params[:name] = clean_params[:username] - end - - clean_params + params.require(:user).permit(:username, :email, :email_confirmation, :name, :first_name, :last_name, :password) end def resource_name diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index e30935be4b6..04d2b3068da 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -21,6 +21,8 @@ class SearchController < ApplicationController return if params[:search].blank? + return unless search_term_valid? + @search_term = params[:search] @scope = search_service.scope @@ -62,6 +64,20 @@ class SearchController < ApplicationController private + def search_term_valid? + unless search_service.valid_query_length? + flash[:alert] = t('errors.messages.search_chars_too_long', count: SearchService::SEARCH_CHAR_LIMIT) + return false + end + + unless search_service.valid_terms_count? + flash[:alert] = t('errors.messages.search_terms_too_long', count: SearchService::SEARCH_TERM_LIMIT) + return false + end + + true + end + def render_commits @search_objects = prepare_commits_for_rendering(@search_objects) end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 0007d5826ba..c29e9d3843b 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -262,7 +262,7 @@ class SessionsController < Devise::SessionsController def log_user_activity(user) login_counter.increment - Users::ActivityService.new(user, 'login').execute + Users::ActivityService.new(user).execute end def load_recaptcha diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb index 54774df5e76..fc073e47368 100644 --- a/app/controllers/snippets_controller.rb +++ b/app/controllers/snippets_controller.rb @@ -50,8 +50,8 @@ class SnippetsController < ApplicationController def create create_params = snippet_params.merge(spammable_params) - - @snippet = CreateSnippetService.new(nil, current_user, create_params).execute + service_response = Snippets::CreateService.new(nil, current_user, create_params).execute + @snippet = service_response.payload[:snippet] move_temporary_files if @snippet.valid? && params[:files] @@ -61,7 +61,8 @@ class SnippetsController < ApplicationController def update update_params = snippet_params.merge(spammable_params) - UpdateSnippetService.new(nil, current_user, @snippet, update_params).execute + service_response = Snippets::UpdateService.new(nil, current_user, update_params).execute(@snippet) + @snippet = service_response.payload[:snippet] recaptcha_check_with_fallback { render :edit } end @@ -96,11 +97,17 @@ class SnippetsController < ApplicationController end def destroy - return access_denied! unless can?(current_user, :admin_personal_snippet, @snippet) - - @snippet.destroy + service_response = Snippets::DestroyService.new(current_user, @snippet).execute - redirect_to snippets_path, status: :found + if service_response.success? + redirect_to dashboard_snippets_path, status: :found + elsif service_response.http_status == 403 + access_denied! + else + redirect_to snippet_path(@snippet), + status: :found, + alert: service_response.message + end end protected diff --git a/app/finders/deployments_finder.rb b/app/finders/deployments_finder.rb index b718b55dd68..0bb8ce6b4da 100644 --- a/app/finders/deployments_finder.rb +++ b/app/finders/deployments_finder.rb @@ -17,6 +17,8 @@ class DeploymentsFinder def execute items = init_collection items = by_updated_at(items) + items = by_environment(items) + items = by_status(items) sort(items) end @@ -58,6 +60,24 @@ class DeploymentsFinder items end + def by_environment(items) + if params[:environment].present? + items.for_environment_name(params[:environment]) + else + items + end + end + + def by_status(items) + return items unless params[:status].present? + + unless Deployment.statuses.key?(params[:status]) + raise ArgumentError, "The deployment status #{params[:status]} is invalid" + end + + items.for_status(params[:status]) + end + def sort_params order_by = ALLOWED_SORT_VALUES.include?(params[:order_by]) ? params[:order_by] : DEFAULT_SORT_VALUE order_direction = ALLOWED_SORT_DIRECTIONS.include?(params[:sort]) ? params[:sort] : DEFAULT_SORT_DIRECTION diff --git a/app/finders/environments_finder.rb b/app/finders/environments_finder.rb index d4e803beb4e..32942c46208 100644 --- a/app/finders/environments_finder.rb +++ b/app/finders/environments_finder.rb @@ -25,25 +25,13 @@ class EnvironmentsFinder .select(:environment_id) environments = project.environments.available - .where(id: environment_ids).order_by_last_deployed_at.to_a + .where(id: environment_ids) - environments.select! do |environment| - Ability.allowed?(current_user, :read_environment, environment) - end - - if ref && commit - environments.select! do |environment| - environment.includes_commit?(commit) - end - end - - if ref && params[:recently_updated] - environments.select! do |environment| - environment.recently_updated_on_branch?(ref) - end + if params[:find_latest] + find_one(environments.order_by_last_deployed_at_desc) + else + find_all(environments.order_by_last_deployed_at.to_a) end - - environments end # rubocop: enable CodeReuse/ActiveRecord @@ -62,6 +50,24 @@ class EnvironmentsFinder private + def find_one(environments) + [environments.find { |environment| valid_environment?(environment) }].compact + end + + def find_all(environments) + environments.select { |environment| valid_environment?(environment) } + end + + def valid_environment?(environment) + # Go in order of cost: SQL calls are cheaper than Gitaly calls + return false unless Ability.allowed?(current_user, :read_environment, environment) + + return false if ref && params[:recently_updated] && !environment.recently_updated_on_branch?(ref) + return false if ref && commit && !environment.includes_commit?(commit) + + true + end + def ref params[:ref].try(:to_s) end diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb index 234b7090fd9..6d059e10d05 100644 --- a/app/finders/events_finder.rb +++ b/app/finders/events_finder.rb @@ -6,7 +6,7 @@ class EventsFinder MAX_PER_PAGE = 100 - attr_reader :source, :params, :current_user + attr_reader :source, :params, :current_user, :scope requires_cross_project_access unless: -> { source.is_a?(Project) }, model: Event @@ -15,6 +15,7 @@ class EventsFinder # Arguments: # source - which user or project to looks for events on # current_user - only return events for projects visible to this user + # scope - return all events across a user's projects # params: # action: string # target_type: string @@ -27,11 +28,12 @@ class EventsFinder def initialize(params = {}) @source = params.delete(:source) @current_user = params.delete(:current_user) + @scope = params.delete(:scope) @params = params end def execute - events = source.events + events = get_events events = by_current_user_access(events) events = by_action(events) @@ -47,6 +49,12 @@ class EventsFinder private + def get_events + return EventCollection.new(current_user.authorized_projects).all_project_events if scope == 'all' + + source.events + end + # rubocop: disable CodeReuse/ActiveRecord def by_current_user_access(events) events.merge(Project.public_or_visible_to_user(current_user)) diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb index d8739c350e4..ffa1552627a 100644 --- a/app/finders/group_members_finder.rb +++ b/app/finders/group_members_finder.rb @@ -1,38 +1,66 @@ # frozen_string_literal: true class GroupMembersFinder < UnionFinder - def initialize(group) + # Params can be any of the following: + # two_factor: string. 'enabled' or 'disabled' are returning different set of data, other values are not effective. + # sort: string + # search: string + + def initialize(group, user = nil) @group = group + @user = user end # rubocop: disable CodeReuse/ActiveRecord - def execute(include_relations: [:inherited, :direct]) - group_members = @group.members + def execute(include_relations: [:inherited, :direct], params: {}) + group_members = group.members relations = [] return group_members if include_relations == [:direct] relations << group_members if include_relations.include?(:direct) - if include_relations.include?(:inherited) && @group.parent + if include_relations.include?(:inherited) && group.parent parents_members = GroupMember.non_request - .where(source_id: @group.ancestors.select(:id)) - .where.not(user_id: @group.users.select(:id)) + .where(source_id: group.ancestors.select(:id)) + .where.not(user_id: group.users.select(:id)) relations << parents_members end if include_relations.include?(:descendants) descendant_members = GroupMember.non_request - .where(source_id: @group.descendants.select(:id)) - .where.not(user_id: @group.users.select(:id)) + .where(source_id: group.descendants.select(:id)) + .where.not(user_id: group.users.select(:id)) relations << descendant_members end - find_union(relations, GroupMember) + return GroupMember.none if relations.empty? + + members = find_union(relations, GroupMember) + filter_members(members, params) end # rubocop: enable CodeReuse/ActiveRecord + + private + + attr_reader :user, :group + + def filter_members(members, params) + members = members.search(params[:search]) if params[:search].present? + members = members.sort_by_attribute(params[:sort]) if params[:sort].present? + + if can_manage_members && params[:two_factor].present? + members = members.filter_by_2fa(params[:two_factor]) + end + + members + end + + def can_manage_members + Ability.allowed?(user, :admin_group_member, group) + end end GroupMembersFinder.prepend_if_ee('EE::GroupMembersFinder') diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index e3ea81d5564..194d7da1cab 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -87,7 +87,7 @@ class IssuableFinder end def valid_params - @valid_params ||= scalar_params + [array_params] + [{ not: [] }] + @valid_params ||= scalar_params + [array_params.merge(not: {})] end end diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index 275a01330bf..410ad645cd9 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -39,6 +39,7 @@ class MergeRequestsFinder < IssuableFinder def filter_items(_items) items = by_commit(super) + items = by_deployment(items) items = by_source_branch(items) items = by_wip(items) items = by_target_branch(items) @@ -101,6 +102,17 @@ class MergeRequestsFinder < IssuableFinder .or(table[:title].matches('WIP %')) .or(table[:title].matches('[WIP]%')) end + + def by_deployment(items) + return items unless deployment_id + + items.includes(:deployment_merge_requests) + .where(deployment_merge_requests: { deployment_id: deployment_id }) + end + + def deployment_id + @deployment_id ||= params[:deployment_id].presence + end end MergeRequestsFinder.prepend_if_ee('EE::MergeRequestsFinder') diff --git a/app/finders/pipelines_finder.rb b/app/finders/pipelines_finder.rb index 5a0d53d9683..48da44123f6 100644 --- a/app/finders/pipelines_finder.rb +++ b/app/finders/pipelines_finder.rb @@ -17,7 +17,7 @@ class PipelinesFinder return Ci::Pipeline.none end - items = pipelines + items = pipelines.no_child items = by_scope(items) items = by_status(items) items = by_ref(items) diff --git a/app/finders/sentry_issue_finder.rb b/app/finders/sentry_issue_finder.rb new file mode 100644 index 00000000000..8b3e7105211 --- /dev/null +++ b/app/finders/sentry_issue_finder.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class SentryIssueFinder + attr_accessor :project, :current_user + + def initialize(project, current_user: nil) + @project = project + @current_user = current_user + end + + def execute(identifier) + return unless authorized? + + SentryIssue + .for_project_and_identifier(project, identifier) + end + + private + + def authorized? + Ability.allowed?(current_user, :read_sentry_issue, project) + end +end diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index a5ddf316572..ea5776534d5 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -57,13 +57,24 @@ class GitlabSchema < GraphQL::Schema object.to_global_id end - def object_from_id(global_id, _ctx = nil) - gid = GlobalID.parse(global_id) - - unless gid - raise Gitlab::Graphql::Errors::ArgumentError, "#{global_id} is not a valid GitLab id." - end + # Find an object by looking it up from its global ID, passed as a string. + # + # This is the composition of 'parse_gid' and 'find_by_gid', see these + # methods for further documentation. + def object_from_id(global_id, ctx = {}) + gid = parse_gid(global_id, ctx) + + find_by_gid(gid) + end + # Find an object by looking it up from its 'GlobalID'. + # + # * For `ApplicationRecord`s, this is equivalent to + # `global_id.model_class.find(gid.model_id)`, but more efficient. + # * For classes that implement `.lazy_find(global_id)`, this class method + # will be called. + # * All other classes will use `GlobalID#find` + def find_by_gid(gid) if gid.model_class < ApplicationRecord Gitlab::Graphql::Loaders::BatchModelLoader.new(gid.model_class, gid.model_id).find elsif gid.model_class.respond_to?(:lazy_find) @@ -73,6 +84,38 @@ class GitlabSchema < GraphQL::Schema end end + # Parse a string to a GlobalID, raising ArgumentError if there are problems + # with it. + # + # Problems that may occur: + # * it may not be syntactically valid + # * it may not match the expected type (see below) + # + # Options: + # * :expected_type [Class] - the type of object this GlobalID should refer to. + # + # e.g. + # + # ``` + # gid = GitlabSchema.parse_gid(my_string, expected_type: ::Project) + # project_id = gid.model_id + # gid.model_class == ::Project + # ``` + def parse_gid(global_id, ctx = {}) + expected_type = ctx[:expected_type] + gid = GlobalID.parse(global_id) + + raise Gitlab::Graphql::Errors::ArgumentError, "#{global_id} is not a valid GitLab id." unless gid + + if expected_type && !gid.model_class.ancestors.include?(expected_type) + vars = { global_id: global_id, expected_type: expected_type } + msg = _('%{global_id} is not a valid id for %{expected_type}.') % vars + raise Gitlab::Graphql::Errors::ArgumentError, msg + end + + gid + end + private def max_query_complexity(ctx) diff --git a/app/graphql/mutations/award_emojis/toggle.rb b/app/graphql/mutations/award_emojis/toggle.rb index d822048f3a6..22eab4812a1 100644 --- a/app/graphql/mutations/award_emojis/toggle.rb +++ b/app/graphql/mutations/award_emojis/toggle.rb @@ -5,10 +5,9 @@ module Mutations 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' + field :toggledOn, GraphQL::BOOLEAN_TYPE, null: false, + description: 'Indicates the status of the emoji. ' \ + 'True if the toggle awarded the emoji, and false if the toggle removed the emoji.' def resolve(args) awardable = authorized_find!(id: args[:awardable_id]) diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb index fe1f543ea1a..4e0e65d09a9 100644 --- a/app/graphql/mutations/snippets/create.rb +++ b/app/graphql/mutations/snippets/create.rb @@ -42,12 +42,13 @@ module Mutations if project_path.present? project = find_project!(project_path: project_path) elsif !can_create_personal_snippet? - raise_resource_not_avaiable_error! + raise_resource_not_available_error! end - snippet = CreateSnippetService.new(project, + service_response = ::Snippets::CreateService.new(project, context[:current_user], args).execute + snippet = service_response.payload[:snippet] { snippet: snippet.valid? ? snippet : nil, diff --git a/app/graphql/mutations/snippets/destroy.rb b/app/graphql/mutations/snippets/destroy.rb index 115fcfd6488..dc9a1e82575 100644 --- a/app/graphql/mutations/snippets/destroy.rb +++ b/app/graphql/mutations/snippets/destroy.rb @@ -15,8 +15,8 @@ module Mutations def resolve(id:) snippet = authorized_find!(id: id) - result = snippet.destroy - errors = result ? [] : [ERROR_MSG] + response = ::Snippets::DestroyService.new(current_user, snippet).execute + errors = response.success? ? [] : [ERROR_MSG] { errors: errors diff --git a/app/graphql/mutations/snippets/mark_as_spam.rb b/app/graphql/mutations/snippets/mark_as_spam.rb index 260a9753f76..8cfbbae7c08 100644 --- a/app/graphql/mutations/snippets/mark_as_spam.rb +++ b/app/graphql/mutations/snippets/mark_as_spam.rb @@ -24,7 +24,7 @@ module Mutations private def mark_as_spam(snippet) - SpamService.new(snippet).mark_as_spam! + Spam::MarkAsSpamService.new(spammable: snippet).execute end def authorized_resource?(snippet) diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb index 27c232bc7f8..b6bdcb9b67b 100644 --- a/app/graphql/mutations/snippets/update.rb +++ b/app/graphql/mutations/snippets/update.rb @@ -33,13 +33,13 @@ module Mutations def resolve(args) snippet = authorized_find!(id: args.delete(:id)) - result = UpdateSnippetService.new(snippet.project, + result = ::Snippets::UpdateService.new(snippet.project, context[:current_user], - snippet, - args).execute + args).execute(snippet) + snippet = result.payload[:snippet] { - snippet: result ? snippet : snippet.reset, + snippet: result.success? ? snippet : snippet.reset, errors: errors_on_object(snippet) } end diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb index 62dcc41dd9c..f2b015edfa1 100644 --- a/app/graphql/resolvers/base_resolver.rb +++ b/app/graphql/resolvers/base_resolver.rb @@ -9,6 +9,10 @@ module Resolvers def resolve(**args) super.first end + + def single? + true + end end end @@ -17,6 +21,10 @@ module Resolvers def resolve(**args) super.last end + + def single? + true + end end end @@ -42,9 +50,13 @@ module Resolvers override :object def object super.tap do |obj| - # If the field this resolver is used in is wrapped in a presenter, go back to it's subject + # If the field this resolver is used in is wrapped in a presenter, unwrap its subject break obj.subject if obj.is_a?(Gitlab::View::Presenter::Base) end end + + def single? + false + end end end diff --git a/app/graphql/resolvers/environments_resolver.rb b/app/graphql/resolvers/environments_resolver.rb new file mode 100644 index 00000000000..868abef98eb --- /dev/null +++ b/app/graphql/resolvers/environments_resolver.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Resolvers + class EnvironmentsResolver < BaseResolver + argument :name, GraphQL::STRING_TYPE, + required: false, + description: 'Name of the environment' + + argument :search, GraphQL::STRING_TYPE, + required: false, + description: 'Search query' + + type Types::EnvironmentType, null: true + + alias_method :project, :object + + def resolve(**args) + return unless project.present? + + EnvironmentsFinder.new(project, context[:current_user], args).find + end + end +end diff --git a/app/graphql/resolvers/projects/grafana_integration_resolver.rb b/app/graphql/resolvers/projects/grafana_integration_resolver.rb new file mode 100644 index 00000000000..030139734ed --- /dev/null +++ b/app/graphql/resolvers/projects/grafana_integration_resolver.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Resolvers + module Projects + class GrafanaIntegrationResolver < BaseResolver + type Types::GrafanaIntegrationType, null: true + + alias_method :project, :object + + def resolve(**args) + return unless project.is_a? Project + + project.grafana_integration + end + 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 index 8daf699a112..0247ec767c8 100644 --- a/app/graphql/types/award_emojis/award_emoji_type.rb +++ b/app/graphql/types/award_emojis/award_emoji_type.rb @@ -4,6 +4,7 @@ module Types module AwardEmojis class AwardEmojiType < BaseObject graphql_name 'AwardEmoji' + description 'An emoji awarded by a user.' authorize :read_emoji diff --git a/app/graphql/types/ci/detailed_status_type.rb b/app/graphql/types/ci/detailed_status_type.rb index d2847641d91..90b5283fc9a 100644 --- a/app/graphql/types/ci/detailed_status_type.rb +++ b/app/graphql/types/ci/detailed_status_type.rb @@ -6,14 +6,24 @@ module Types class DetailedStatusType < BaseObject graphql_name 'DetailedStatus' - field :group, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :icon, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :favicon, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :details_path, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :has_details, GraphQL::BOOLEAN_TYPE, null: false, method: :has_details? # rubocop:disable Graphql/Descriptions - field :label, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :text, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :tooltip, GraphQL::STRING_TYPE, null: false, method: :status_tooltip # rubocop:disable Graphql/Descriptions + field :group, GraphQL::STRING_TYPE, null: false, + description: 'Group of the pipeline status' + field :icon, GraphQL::STRING_TYPE, null: false, + description: 'Icon of the pipeline status' + field :favicon, GraphQL::STRING_TYPE, null: false, + description: 'Favicon of the pipeline status' + field :details_path, GraphQL::STRING_TYPE, null: false, + description: 'Path of the details for the pipeline status' + field :has_details, GraphQL::BOOLEAN_TYPE, null: false, + description: 'Indicates if the pipeline status has further details', + method: :has_details? + field :label, GraphQL::STRING_TYPE, null: false, + description: 'Label of the pipeline status' + field :text, GraphQL::STRING_TYPE, null: false, + description: 'Text of the pipeline status' + field :tooltip, GraphQL::STRING_TYPE, null: false, + description: 'Tooltip associated with the pipeline status', + method: :status_tooltip end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb index dfcfd6211bc..d77b2a2ba32 100644 --- a/app/graphql/types/ci/pipeline_type.rb +++ b/app/graphql/types/ci/pipeline_type.rb @@ -9,29 +9,34 @@ module Types expose_permissions Types::PermissionTypes::Ci::Pipeline - field :id, GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :iid, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the pipeline' + field :iid, GraphQL::STRING_TYPE, null: false, + description: 'Internal ID of the pipeline' - field :sha, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :before_sha, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :status, PipelineStatusEnum, null: false # rubocop:disable Graphql/Descriptions - field :detailed_status, # rubocop:disable Graphql/Descriptions - Types::Ci::DetailedStatusType, - null: false, + field :sha, GraphQL::STRING_TYPE, null: false, + description: "SHA of the pipeline's commit" + field :before_sha, GraphQL::STRING_TYPE, null: true, + description: 'Base SHA of the source branch' + field :status, PipelineStatusEnum, null: false, + description: "Status of the pipeline (#{::Ci::Pipeline.all_state_names.compact.join(', ').upcase})" + field :detailed_status, Types::Ci::DetailedStatusType, null: false, + description: 'Detailed status of the pipeline', resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) } - field :duration, - GraphQL::INT_TYPE, - null: true, - description: "Duration of the pipeline in seconds" - field :coverage, - GraphQL::FLOAT_TYPE, - null: true, - description: "Coverage percentage" - field :created_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions - field :updated_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions - field :started_at, Types::TimeType, null: true # rubocop:disable Graphql/Descriptions - field :finished_at, Types::TimeType, null: true # rubocop:disable Graphql/Descriptions - field :committed_at, Types::TimeType, null: true # rubocop:disable Graphql/Descriptions + field :duration, GraphQL::INT_TYPE, null: true, + description: 'Duration of the pipeline in seconds' + field :coverage, GraphQL::FLOAT_TYPE, null: true, + description: 'Coverage percentage' + field :created_at, Types::TimeType, null: false, + description: "Timestamp of the pipeline's creation" + field :updated_at, Types::TimeType, null: false, + description: "Timestamp of the pipeline's last activity" + field :started_at, Types::TimeType, null: true, + description: 'Timestamp when the pipeline was started' + field :finished_at, Types::TimeType, null: true, + description: "Timestamp of the pipeline's completion" + field :committed_at, Types::TimeType, null: true, + description: "Timestamp of the pipeline's commit" # TODO: Add triggering user as a type end diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb new file mode 100644 index 00000000000..ad65caa24a6 --- /dev/null +++ b/app/graphql/types/environment_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + class EnvironmentType < BaseObject + graphql_name 'Environment' + description 'Describes where code is deployed for a project' + + authorize :read_environment + + field :name, GraphQL::STRING_TYPE, null: false, + description: 'Human-readable name of the environment' + + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the environment' + end +end diff --git a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb index c680f387a9a..af6d8818d90 100644 --- a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb +++ b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb @@ -76,6 +76,12 @@ module Types field :last_release_short_version, GraphQL::STRING_TYPE, null: true, description: "Release version the error was last seen" + field :gitlab_commit, GraphQL::STRING_TYPE, + null: true, + description: "GitLab commit SHA attributed to the Error based on the release version" + field :gitlab_commit_path, GraphQL::STRING_TYPE, + null: true, + description: "Path to the GitLab page for the GitLab commit attributed to the error" def first_seen DateTime.parse(object.first_seen) diff --git a/app/graphql/types/grafana_integration_type.rb b/app/graphql/types/grafana_integration_type.rb new file mode 100644 index 00000000000..e6c865fea53 --- /dev/null +++ b/app/graphql/types/grafana_integration_type.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + class GrafanaIntegrationType < ::Types::BaseObject + graphql_name 'GrafanaIntegration' + + authorize :admin_operations + + field :id, GraphQL::ID_TYPE, null: false, + description: 'Internal ID of the Grafana integration' + field :grafana_url, GraphQL::STRING_TYPE, null: false, + description: 'Url for the Grafana host for the Grafana integration' + field :token, GraphQL::STRING_TYPE, null: false, + description: 'API token for the Grafana integration' + field :enabled, GraphQL::BOOLEAN_TYPE, null: false, + description: 'Indicates whether Grafana integration is enabled' + + field :created_at, Types::TimeType, null: false, + description: 'Timestamp of the issue\'s creation' + field :updated_at, Types::TimeType, null: false, + description: 'Timestamp of the issue\'s last activity' + end +end diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index 386ae6ed4a3..393948fcede 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -17,6 +17,9 @@ module Types group.avatar_url(only_path: false) end + field :mentions_disabled, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if a group is disabled from getting mentioned' + field :parent, GroupType, null: true, description: 'Parent group', resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, obj.parent_id).find } diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index 4cbb849da3a..11850e5865f 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -69,7 +69,7 @@ module Types field :participants, Types::UserType.connection_type, null: true, complexity: 5, description: 'List of participants in the issue' field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false, complexity: 5, - description: 'Boolean flag for whether the currently logged in user is subscribed to this issue' + description: 'Indicates the currently logged in user is subscribed to the issue' field :time_estimate, GraphQL::INT_TYPE, null: false, description: 'Time estimate of the issue' field :total_time_spent, GraphQL::INT_TYPE, null: false, diff --git a/app/graphql/types/permission_types/base_permission_type.rb b/app/graphql/types/permission_types/base_permission_type.rb index 73049ebed7b..deb8560bd79 100644 --- a/app/graphql/types/permission_types/base_permission_type.rb +++ b/app/graphql/types/permission_types/base_permission_type.rb @@ -25,7 +25,7 @@ module Types kword_args = kword_args.reverse_merge( name: name, type: GraphQL::BOOLEAN_TYPE, - description: "Whether or not a user can perform `#{name}` on this resource", + description: "Indicates the user can perform `#{name}` on this resource", null: false) field(**kword_args) # rubocop:disable Graphql/Descriptions diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index bd80ad7ff74..5ece4926951 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -46,7 +46,7 @@ module Types description: 'Timestamp of the project last activity' field :archived, GraphQL::BOOLEAN_TYPE, null: true, - description: 'Archived status of the project' + description: 'Indicates the archived status of the project' field :visibility, GraphQL::STRING_TYPE, null: true, description: 'Visibility of the project' @@ -102,6 +102,10 @@ module Types description: 'Indicates if a link to create or view a merge request should display after a push to Git repositories of the project from the command line' field :remove_source_branch_after_merge, GraphQL::BOOLEAN_TYPE, null: true, description: 'Indicates if `Delete source branch` option should be enabled by default for all new merge requests of the project' + field :autoclose_referenced_issues, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if issues referenced by merge requests and commits within the default branch are closed automatically' + field :suggestion_commit_message, GraphQL::STRING_TYPE, null: true, + description: 'The commit message used to apply merge request suggestions' field :namespace, Types::NamespaceType, null: true, description: 'Namespace of the project' @@ -134,6 +138,12 @@ module Types description: 'Issues of the project', resolver: Resolvers::IssuesResolver + field :environments, + Types::EnvironmentType.connection_type, + null: true, + description: 'Environments of the project', + resolver: Resolvers::EnvironmentsResolver + field :issue, Types::IssueType, null: true, @@ -152,6 +162,12 @@ module Types description: 'Detailed version of a Sentry error on the project', resolver: Resolvers::ErrorTracking::SentryDetailedErrorResolver + field :grafana_integration, + Types::GrafanaIntegrationType, + null: true, + description: 'Grafana integration details for the project', + resolver: Resolvers::Projects::GrafanaIntegrationResolver + field :snippets, Types::SnippetType.connection_type, null: true, diff --git a/app/graphql/types/tree/blob_type.rb b/app/graphql/types/tree/blob_type.rb index 0886a0ba98e..22349203519 100644 --- a/app/graphql/types/tree/blob_type.rb +++ b/app/graphql/types/tree/blob_type.rb @@ -10,10 +10,13 @@ module Types graphql_name 'Blob' - field :web_url, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :lfs_oid, GraphQL::STRING_TYPE, null: true, resolve: -> (blob, args, ctx) do # rubocop:disable Graphql/Descriptions - Gitlab::Graphql::Loaders::BatchLfsOidLoader.new(blob.repository, blob.id).find - end + field :web_url, GraphQL::STRING_TYPE, null: true, + description: 'Web URL of the blob' + field :lfs_oid, GraphQL::STRING_TYPE, null: true, + description: 'LFS ID of the blob', + resolve: -> (blob, args, ctx) do + Gitlab::Graphql::Loaders::BatchLfsOidLoader.new(blob.repository, blob.id).find + end # rubocop: enable Graphql/AuthorizeTypes end end diff --git a/app/graphql/types/tree/entry_type.rb b/app/graphql/types/tree/entry_type.rb index 87a3eced896..b40e38ec9d1 100644 --- a/app/graphql/types/tree/entry_type.rb +++ b/app/graphql/types/tree/entry_type.rb @@ -4,12 +4,18 @@ module Types module EntryType include Types::BaseInterface - field :id, GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :sha, GraphQL::STRING_TYPE, null: false, description: "Last commit sha for entry", method: :id - field :name, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :type, Tree::TypeEnum, null: false # rubocop:disable Graphql/Descriptions - field :path, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :flat_path, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the entry' + field :sha, GraphQL::STRING_TYPE, null: false, + description: 'Last commit sha for the entry', method: :id + field :name, GraphQL::STRING_TYPE, null: false, + description: 'Name of the entry' + field :type, Tree::TypeEnum, null: false, + description: 'Type of tree entry' + field :path, GraphQL::STRING_TYPE, null: false, + description: 'Path of the entry' + field :flat_path, GraphQL::STRING_TYPE, null: false, + description: 'Flat path of the entry' end end end diff --git a/app/graphql/types/tree/submodule_type.rb b/app/graphql/types/tree/submodule_type.rb index d8e2ab4dd68..d41fa4afd4b 100644 --- a/app/graphql/types/tree/submodule_type.rb +++ b/app/graphql/types/tree/submodule_type.rb @@ -8,8 +8,10 @@ module Types graphql_name 'Submodule' - field :web_url, type: GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :tree_url, type: GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions + field :web_url, type: GraphQL::STRING_TYPE, null: true, + description: 'Web URL for the sub-module' + field :tree_url, type: GraphQL::STRING_TYPE, null: true, + description: 'Tree URL for the sub-module' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/tree/tree_entry_type.rb b/app/graphql/types/tree/tree_entry_type.rb index 904c7dfb795..81a7a7e66ae 100644 --- a/app/graphql/types/tree/tree_entry_type.rb +++ b/app/graphql/types/tree/tree_entry_type.rb @@ -11,7 +11,8 @@ module Types graphql_name 'TreeEntry' description 'Represents a directory' - field :web_url, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions + field :web_url, GraphQL::STRING_TYPE, null: true, + description: 'Web URL for the tree entry (directory)' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/tree/tree_type.rb b/app/graphql/types/tree/tree_type.rb index 56d544b5fd1..b9fb6b28e71 100644 --- a/app/graphql/types/tree/tree_type.rb +++ b/app/graphql/types/tree/tree_type.rb @@ -11,19 +11,23 @@ module Types null: true, complexity: 10, calls_gitaly: true, resolver: Resolvers::LastCommitResolver, description: 'Last commit for the tree' - field :trees, Types::Tree::TreeEntryType.connection_type, null: false, resolve: -> (obj, args, ctx) do # rubocop:disable Graphql/Descriptions - Gitlab::Graphql::Representation::TreeEntry.decorate(obj.trees, obj.repository) - end + field :trees, Types::Tree::TreeEntryType.connection_type, null: false, + description: 'Trees of the tree', + resolve: -> (obj, args, ctx) do + Gitlab::Graphql::Representation::TreeEntry.decorate(obj.trees, obj.repository) + end - # rubocop:disable Graphql/Descriptions - field :submodules, Types::Tree::SubmoduleType.connection_type, null: false, calls_gitaly: true, resolve: -> (obj, args, ctx) do - Gitlab::Graphql::Representation::SubmoduleTreeEntry.decorate(obj.submodules, obj) - end - # rubocop:enable Graphql/Descriptions + field :submodules, Types::Tree::SubmoduleType.connection_type, null: false, + description: 'Sub-modules of the tree', + calls_gitaly: true, resolve: -> (obj, args, ctx) do + Gitlab::Graphql::Representation::SubmoduleTreeEntry.decorate(obj.submodules, obj) + end - field :blobs, Types::Tree::BlobType.connection_type, null: false, calls_gitaly: true, resolve: -> (obj, args, ctx) do # rubocop:disable Graphql/Descriptions - Gitlab::Graphql::Representation::TreeEntry.decorate(obj.blobs, obj.repository) - end + field :blobs, Types::Tree::BlobType.connection_type, null: false, + description: 'Blobs of the tree', + calls_gitaly: true, resolve: -> (obj, args, ctx) do + Gitlab::Graphql::Representation::TreeEntry.decorate(obj.blobs, obj.repository) + end # rubocop: enable Graphql/AuthorizeTypes end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 8389272fd35..8833b36c42d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -198,7 +198,7 @@ module ApplicationHelper end def external_storage_url_or_path(path, project = @project) - return path unless static_objects_external_storage_enabled? + return path if @snippet || !static_objects_external_storage_enabled? uri = URI(Gitlab::CurrentSettings.static_objects_external_storage_url) path = URI(path) # `path` could have query parameters, so we need to split query and path apart diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 71e4195c50f..0e14db6ddbf 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -202,6 +202,7 @@ module ApplicationSettingsHelper :enabled_git_access_protocol, :enforce_terms, :first_day_of_week, + :force_pages_access_control, :gitaly_timeout_default, :gitaly_timeout_medium, :gitaly_timeout_fast, @@ -334,6 +335,28 @@ module ApplicationSettingsHelper def omnibus_protected_paths_throttle? Rack::Attack.throttles.key?('protected paths') end + + def self_monitoring_project_data + { + 'create_self_monitoring_project_path' => + create_self_monitoring_project_admin_application_settings_path, + + 'status_create_self_monitoring_project_path' => + status_create_self_monitoring_project_admin_application_settings_path, + + 'delete_self_monitoring_project_path' => + delete_self_monitoring_project_admin_application_settings_path, + + 'status_delete_self_monitoring_project_path' => + status_delete_self_monitoring_project_admin_application_settings_path, + + 'self_monitoring_project_exists' => + Gitlab::CurrentSettings.instance_administration_project.present?.to_s, + + 'self_monitoring_project_full_path' => + Gitlab::CurrentSettings.instance_administration_project&.full_path + } + end end ApplicationSettingsHelper.prepend_if_ee('EE::ApplicationSettingsHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb index 21e57a8d391..b95fd8800c0 100644 --- a/app/helpers/broadcast_messages_helper.rb +++ b/app/helpers/broadcast_messages_helper.rb @@ -1,19 +1,29 @@ # frozen_string_literal: true module BroadcastMessagesHelper - def current_broadcast_messages - BroadcastMessage.current(request.path) + def current_broadcast_banner_messages + BroadcastMessage.current_banner_messages(request.path) end - def broadcast_message(message) + def current_broadcast_notification_message + BroadcastMessage.current_notification_messages(request.path).last + end + + def broadcast_message(message, opts = {}) return unless message.present? - content_tag :div, dir: 'auto', class: 'broadcast-message', style: broadcast_message_style(message) do - sprite_icon('bullhorn', size: 16, css_class: 'vertical-align-text-top mr-2') << ' ' << render_broadcast_message(message) + classes = "broadcast-#{message.broadcast_type}-message #{opts[:preview] && 'preview'}" + + content_tag :div, dir: 'auto', class: classes, style: broadcast_message_style(message) do + concat sprite_icon('bullhorn', size: 16, css_class: 'vertical-align-text-top') + concat ' ' + concat render_broadcast_message(message) end end def broadcast_message_style(broadcast_message) + return '' if broadcast_message.notification? + style = [] if broadcast_message.color.present? @@ -40,4 +50,8 @@ module BroadcastMessagesHelper def render_broadcast_message(broadcast_message) Banzai.render_field(broadcast_message, :message).html_safe end + + def broadcast_type_options + BroadcastMessage.broadcast_types.keys.map { |w| [w.humanize, w] } + end end diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index 4471d5b64b2..80d1b7e7edb 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -62,6 +62,7 @@ module CiStatusHelper status.humanize end + # rubocop:disable Metrics/CyclomaticComplexity def ci_icon_for_status(status, size: 16) if detailed_status?(status) return sprite_icon(status.icon, size: size) @@ -77,6 +78,8 @@ module CiStatusHelper 'status_failed' when 'pending' 'status_pending' + when 'waiting_for_resource' + 'status_pending' when 'preparing' 'status_preparing' when 'running' @@ -97,6 +100,7 @@ module CiStatusHelper sprite_icon(icon_name, size: size) end + # rubocop:enable Metrics/CyclomaticComplexity def ci_icon_class_for_status(status) group = detailed_status?(status) ? status.group : status.dasherize diff --git a/app/helpers/container_expiration_policies_helper.rb b/app/helpers/container_expiration_policies_helper.rb index 17791e7b0ff..5fb7b5afa6e 100644 --- a/app/helpers/container_expiration_policies_helper.rb +++ b/app/helpers/container_expiration_policies_helper.rb @@ -3,19 +3,25 @@ module ContainerExpirationPoliciesHelper def cadence_options ContainerExpirationPolicy.cadence_options.map do |key, val| - { key: key.to_s, label: val } + { key: key.to_s, label: val }.tap do |base| + base[:default] = true if key.to_s == '1d' + end end end def keep_n_options ContainerExpirationPolicy.keep_n_options.map do |key, val| - { key: key, label: val } + { key: key, label: val }.tap do |base| + base[:default] = true if key == 10 + end end end def older_than_options ContainerExpirationPolicy.older_than_options.map do |key, val| - { key: key.to_s, label: val } + { key: key.to_s, label: val }.tap do |base| + base[:default] = true if key.to_s == '30d' + end end end end diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb index 679622897aa..b38feb0fb6c 100644 --- a/app/helpers/dashboard_helper.rb +++ b/app/helpers/dashboard_helper.rb @@ -35,7 +35,7 @@ module DashboardHelper tag.p(aria: { label: label }) do concat(link_or_title) - concat(tag.span(class: ['light', 'float-right']) do + concat(tag.span(class: %w[light float-right]) do boolean_to_icon(enabled) end) @@ -58,6 +58,10 @@ module DashboardHelper links += [:activity, :milestones] end + if can?(current_user, :read_instance_statistics) + links << :analytics + end + links end end diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index 59972118ae3..993c18f9229 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -29,8 +29,10 @@ module EnvironmentsHelper "empty-no-data-small-svg-path" => image_path('illustrations/chart-empty-state-small.svg'), "empty-unable-to-connect-svg-path" => image_path('illustrations/monitoring/unable_to_connect.svg'), "metrics-endpoint" => additional_metrics_project_environment_path(project, environment, format: :json), + "dashboards-endpoint" => project_performance_monitoring_dashboards_path(project, format: :json), "dashboard-endpoint" => metrics_dashboard_project_environment_path(project, environment, format: :json), "deployments-endpoint" => project_environment_deployments_path(project, environment, format: :json), + "default-branch" => project.default_branch, "environments-endpoint": project_environments_path(project, format: :json), "project-path" => project_path(project), "tags-path" => project_tags_path(project), diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index 78c41257404..1fb0b83b010 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -153,6 +153,29 @@ module GitlabRoutingHelper # Artifacts + # Rails path generators are slow because they need to do large regex comparisons + # against the arguments. We can speed this up 10x by generating the strings directly. + + # /*namespace_id/:project_id/-/jobs/:job_id/artifacts/download(.:format) + def fast_download_project_job_artifacts_path(project, job) + expose_fast_artifacts_path(project, job, :download) + end + + # /*namespace_id/:project_id/-/jobs/:job_id/artifacts/keep(.:format) + def fast_keep_project_job_artifacts_path(project, job) + expose_fast_artifacts_path(project, job, :keep) + end + + # /*namespace_id/:project_id/-/jobs/:job_id/artifacts/browse(/*path) + def fast_browse_project_job_artifacts_path(project, job) + expose_fast_artifacts_path(project, job, :browse) + end + + def expose_fast_artifacts_path(project, job, action) + path = "#{project.full_path}/-/jobs/#{job.id}/artifacts/#{action}" + Gitlab::Utils.append_path(Gitlab.config.gitlab.relative_url_root, path) + end + def artifacts_action_path(path, project, build) action, path_params = path.split('/', 2) args = [project, build, path_params] diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb index a8f6c974bbd..1952325c504 100644 --- a/app/helpers/groups/group_members_helper.rb +++ b/app/helpers/groups/group_members_helper.rb @@ -4,6 +4,10 @@ module Groups::GroupMembersHelper def group_member_select_options { multiple: true, class: 'input-clamp qa-member-select-field ', scope: :all, email_user: true } end + + def render_invite_member_for_group(group, default_access_level) + render 'shared/members/invite_member', submit_url: group_group_members_path(group), access_levels: GroupMember.access_level_roles, default_access_level: default_access_level + end end Groups::GroupMembersHelper.prepend_if_ee('EE::Groups::GroupMembersHelper') diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb index 8e50bbc6c04..e4d0e605254 100644 --- a/app/helpers/ide_helper.rb +++ b/app/helpers/ide_helper.rb @@ -10,7 +10,8 @@ module IdeHelper "promotion-svg-path": image_path('illustrations/web-ide_promotion.svg'), "ci-help-page-path" => help_page_path('ci/quick_start/README'), "web-ide-help-page-path" => help_page_path('user/project/web_ide/index.html'), - "clientside-preview-enabled": Gitlab::CurrentSettings.current_application_settings.web_ide_clientside_preview_enabled.to_s + "clientside-preview-enabled": Gitlab::CurrentSettings.current_application_settings.web_ide_clientside_preview_enabled.to_s, + "render-whitespace-in-code": current_user.render_whitespace_in_code.to_s } end end diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index e1e756c2f4c..d6e466d4678 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -132,6 +132,7 @@ module MarkupHelper pipeline: :wiki, project: @project, project_wiki: @project_wiki, + repository: @project_wiki.repository, page_slug: wiki_page.slug, issuable_state_filter_enabled: true ) @@ -153,7 +154,9 @@ module MarkupHelper else other_markup_unsafe(file_name, text, context) end - rescue RuntimeError + rescue StandardError => e + Gitlab::ErrorTracking.track_exception(e, project_id: @project&.id, file_name: file_name, context: context) + simple_format(text) end @@ -280,7 +283,7 @@ module MarkupHelper context.reverse_merge!( current_user: (current_user if defined?(current_user)), - # RelativeLinkFilter + # RepositoryLinkFilter and UploadLinkFilter commit: @commit, project_wiki: @project_wiki, ref: @ref, diff --git a/app/helpers/projects/error_tracking_helper.rb b/app/helpers/projects/error_tracking_helper.rb index de21a78f5f0..ed5c7640ec1 100644 --- a/app/helpers/projects/error_tracking_helper.rb +++ b/app/helpers/projects/error_tracking_helper.rb @@ -10,6 +10,8 @@ module Projects::ErrorTrackingHelper 'user-can-enable-error-tracking' => can?(current_user, :admin_operations, project).to_s, 'enable-error-tracking-link' => project_settings_operations_path(project), 'error-tracking-enabled' => error_tracking_enabled.to_s, + 'project-path' => project.full_path, + 'list-path' => project_error_tracking_index_path(project), 'illustration-path' => image_path('illustrations/cluster_popover.svg') } end @@ -18,8 +20,12 @@ module Projects::ErrorTrackingHelper opts = [project, issue_id, { format: :json }] { - 'project-issues-path' => project_issues_path(project), + 'issue-id' => issue_id, + 'project-path' => project.full_path, + 'list-path' => project_error_tracking_index_path(project), 'issue-details-path' => details_project_error_tracking_index_path(*opts), + 'issue-update-path' => update_project_error_tracking_index_path(*opts), + 'project-issues-path' => project_issues_path(project), 'issue-stack-trace-path' => stack_trace_project_error_tracking_index_path(*opts) } end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index d683faf6a20..63f1f24b611 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -563,6 +563,7 @@ module ProjectsHelper requestAccessEnabled: !!project.request_access_enabled, issuesAccessLevel: feature.issues_access_level, repositoryAccessLevel: feature.repository_access_level, + forkingAccessLevel: feature.forking_access_level, mergeRequestsAccessLevel: feature.merge_requests_access_level, buildsAccessLevel: feature.builds_access_level, wikiAccessLevel: feature.wiki_access_level, @@ -587,6 +588,7 @@ module ProjectsHelper lfsHelpPath: help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs'), pagesAvailable: Gitlab.config.pages.enabled, pagesAccessControlEnabled: Gitlab.config.pages.access_control, + pagesAccessControlForced: ::Gitlab::Pages.access_control_is_forced?, pagesHelpPath: help_page_path('user/project/pages/introduction', anchor: 'gitlab-pages-access-control-core') } end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index a89fea4b7b8..9a5c5f274a0 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -143,7 +143,7 @@ module SearchHelper # Autocomplete results for the current project, if it's defined def project_autocomplete - if @project && @project.repository.exists? && @project.repository.root_ref + if @project && @project.repository.root_ref ref = @ref || @project.repository.root_ref [ diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb index 90c54123597..4d0f9e530fb 100644 --- a/app/helpers/selects_helper.rb +++ b/app/helpers/selects_helper.rb @@ -85,7 +85,8 @@ module SelectsHelper first_user: opts[:first_user] && current_user ? current_user.username : false, current_user: opts[:current_user] || false, author_id: opts[:author_id] || '', - skip_users: opts[:skip_users] ? opts[:skip_users].map(&:id) : nil + skip_users: opts[:skip_users] ? opts[:skip_users].map(&:id) : nil, + qa_selector: opts[:qa_selector] || '' } end end diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb index 1c7690f30d2..fd7e58826b5 100644 --- a/app/helpers/snippets_helper.rb +++ b/app/helpers/snippets_helper.rb @@ -127,7 +127,7 @@ module SnippetsHelper return unless attrs = snippet_badge_attributes(snippet) css_class, text = attrs - tag.span(class: ['badge', 'badge-gray']) do + tag.span(class: %w[badge badge-gray]) do concat(tag.i(class: ['fa', css_class])) concat(' ') concat(text) diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb index 11b78b8fd59..b3eee25674b 100644 --- a/app/helpers/user_callouts_helper.rb +++ b/app/helpers/user_callouts_helper.rb @@ -27,7 +27,7 @@ module UserCalloutsHelper end def show_tabs_feature_highlight? - !user_dismissed?(TABS_POSITION_HIGHLIGHT) && !Rails.env.test? + current_user && !user_dismissed?(TABS_POSITION_HIGHLIGHT) && !Rails.env.test? end private diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index 6274879ee99..5c957437039 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -51,7 +51,7 @@ module Emails add_project_headers headers['X-GitLab-Author'] = @message.author_username - mail(from: sender(@message.author_id, @message.send_from_committer_email?), + mail(from: sender(@message.author_id, send_from_user_email: @message.send_from_committer_email?), reply_to: @message.reply_to, subject: @message.subject) end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index c7cfefeec9b..92939136de2 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -59,11 +59,11 @@ class Notify < BaseMailer # Return an email address that displays the name of the sender. # Only the displayed name changes; the actual email address is always the same. - def sender(sender_id, send_from_user_email = false) + def sender(sender_id, send_from_user_email: false, sender_name: nil) return unless sender = User.find(sender_id) address = default_sender_address - address.display_name = sender.name + address.display_name = sender_name.presence || sender.name if send_from_user_email && can_send_from_user_email?(sender) address.address = sender.email diff --git a/app/models/active_session.rb b/app/models/active_session.rb index 3ecc3137157..f37da1b7f59 100644 --- a/app/models/active_session.rb +++ b/app/models/active_session.rb @@ -6,9 +6,11 @@ class ActiveSession SESSION_BATCH_SIZE = 200 ALLOWED_NUMBER_OF_ACTIVE_SESSIONS = 100 + attr_writer :session_id + attr_accessor :created_at, :updated_at, - :session_id, :ip_address, - :browser, :os, :device_name, :device_type, + :ip_address, :browser, :os, + :device_name, :device_type, :is_impersonated def current?(session) @@ -21,6 +23,11 @@ class ActiveSession device_type&.titleize end + def public_id + encrypted_id = Gitlab::CryptoHelper.aes256_gcm_encrypt(session_id) + CGI.escape(encrypted_id) + end + def self.set(user, request) Gitlab::Redis::SharedState.with do |redis| session_id = request.session.id @@ -70,6 +77,11 @@ class ActiveSession end end + def self.destroy_with_public_id(user, public_id) + session_id = decrypt_public_id(public_id) + destroy(user, session_id) unless session_id.nil? + end + def self.destroy_sessions(redis, user, session_ids) key_names = session_ids.map {|session_id| key_name(user.id, session_id) } session_names = session_ids.map {|session_id| "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}" } @@ -146,9 +158,9 @@ class ActiveSession # remove sessions if there are more than ALLOWED_NUMBER_OF_ACTIVE_SESSIONS. sessions = active_session_entries(session_ids, user.id, redis) sessions.sort_by! {|session| session.updated_at }.reverse! - sessions = sessions.drop(ALLOWED_NUMBER_OF_ACTIVE_SESSIONS) - sessions = sessions.map { |session| session.session_id } - destroy_sessions(redis, user, sessions) if sessions.any? + destroyable_sessions = sessions.drop(ALLOWED_NUMBER_OF_ACTIVE_SESSIONS) + destroyable_session_ids = destroyable_sessions.map { |session| session.send :session_id } # rubocop:disable GitlabSecurity/PublicSend + destroy_sessions(redis, user, destroyable_session_ids) if destroyable_session_ids.any? end def self.cleaned_up_lookup_entries(redis, user) @@ -167,4 +179,15 @@ class ActiveSession entries.compact end + + private_class_method def self.decrypt_public_id(public_id) + decoded_id = CGI.unescape(public_id) + Gitlab::CryptoHelper.aes256_gcm_decrypt(decoded_id) + rescue + nil + end + + private + + attr_reader :session_id end diff --git a/app/models/appearance.rb b/app/models/appearance.rb index 2815a117f7f..1104b676bc4 100644 --- a/app/models/appearance.rb +++ b/app/models/appearance.rb @@ -18,6 +18,11 @@ class Appearance < ApplicationRecord validate :single_appearance_row, on: :create + default_value_for :title, '' + default_value_for :description, '' + default_value_for :new_project_guidelines, '' + default_value_for :header_message, '' + default_value_for :footer_message, '' default_value_for :message_background_color, '#E75E40' default_value_for :message_font_color, '#FFFFFF' default_value_for :email_header_and_footer_enabled, false diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 456b6430088..10d15e84b8d 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -11,6 +11,7 @@ class ApplicationSetting < ApplicationRecord add_authentication_token_field :static_objects_external_storage_auth_token belongs_to :instance_administration_project, class_name: "Project" + belongs_to :instance_administrators_group, class_name: "Group" # Include here so it can override methods from # `add_authentication_token_field` @@ -121,6 +122,11 @@ class ApplicationSetting < ApplicationRecord presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :max_pages_size, + presence: true, + numericality: { only_integer: true, greater_than: 0, + less_than: ::Gitlab::Pages::MAX_SIZE / 1.megabyte } + validates :default_artifacts_expire_in, presence: true, duration: true validates :container_registry_token_expire_delay, @@ -164,7 +170,11 @@ class ApplicationSetting < ApplicationRecord validates :gitaly_timeout_default, presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } + numericality: { + only_integer: true, + greater_than_or_equal_to: 0, + less_than_or_equal_to: Settings.gitlab.max_request_duration_seconds + } validates :gitaly_timeout_medium, presence: true, diff --git a/app/models/blob.rb b/app/models/blob.rb index 0a425f2b961..42ee00bc196 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -51,6 +51,7 @@ class Blob < SimpleDelegator BlobViewer::Contributing, BlobViewer::Changelog, + BlobViewer::CargoToml, BlobViewer::Cartfile, BlobViewer::ComposerJson, BlobViewer::Gemfile, diff --git a/app/models/blob_viewer/cargo_toml.rb b/app/models/blob_viewer/cargo_toml.rb new file mode 100644 index 00000000000..2f1ebd25b4f --- /dev/null +++ b/app/models/blob_viewer/cargo_toml.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module BlobViewer + class CargoToml < DependencyManager + include Static + + self.file_types = %i(cargo_toml) + + def manager_name + 'Cargo' + end + + def manager_url + 'https://crates.io/' + end + end +end diff --git a/app/models/board.rb b/app/models/board.rb index f3f938224a4..38bbb550044 100644 --- a/app/models/board.rb +++ b/app/models/board.rb @@ -11,6 +11,8 @@ class Board < ApplicationRecord validates :group, presence: true, unless: :project scope :with_associations, -> { preload(:destroyable_lists) } + scope :order_by_name_asc, -> { order(arel_table[:name].lower.asc) } + scope :first_board, -> { where(id: self.order_by_name_asc.limit(1).select(:id)) } def project_needed? !group diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 6c51f650b6a..e6d41dd2779 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true module Ci - class Bridge < CommitStatus - include Ci::Processable + class Bridge < Ci::Processable include Ci::Contextable include Ci::PipelineDelegator include Importable @@ -54,6 +53,10 @@ module Ci def to_partial_path 'projects/generic_commit_statuses/generic_commit_status' end + + def yaml_for_downstream + nil + end end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 7e7c580a48e..369a793f3d5 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true module Ci - class Build < CommitStatus - include Ci::Processable + class Build < Ci::Processable include Ci::Metadatable include Ci::Contextable include Ci::PipelineDelegator @@ -23,6 +22,7 @@ module Ci belongs_to :runner belongs_to :trigger_request belongs_to :erased_by, class_name: 'User' + belongs_to :resource_group, class_name: 'Ci::ResourceGroup', inverse_of: :builds RUNNER_FEATURES = { upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? }, @@ -34,6 +34,7 @@ module Ci }.freeze has_one :deployment, as: :deployable, class_name: 'Deployment' + has_one :resource, class_name: 'Ci::Resource', inverse_of: :build has_many :trace_sections, class_name: 'Ci::BuildTraceSection' has_many :trace_chunks, class_name: 'Ci::BuildTraceChunk', foreign_key: :build_id @@ -204,9 +205,25 @@ module Ci state_machine :status do event :enqueue do + transition [:created, :skipped, :manual, :scheduled] => :waiting_for_resource, if: :requires_resource? transition [:created, :skipped, :manual, :scheduled] => :preparing, if: :any_unmet_prerequisites? end + event :enqueue_scheduled do + transition scheduled: :waiting_for_resource, if: :requires_resource? + transition scheduled: :preparing, if: :any_unmet_prerequisites? + transition scheduled: :pending + end + + event :enqueue_waiting_for_resource do + transition waiting_for_resource: :preparing, if: :any_unmet_prerequisites? + transition waiting_for_resource: :pending + end + + event :enqueue_preparing do + transition preparing: :pending + end + event :actionize do transition created: :manual end @@ -219,14 +236,8 @@ module Ci transition scheduled: :manual end - event :enqueue_scheduled do - transition scheduled: :preparing, if: ->(build) do - build.scheduled_at&.past? && build.any_unmet_prerequisites? - end - - transition scheduled: :pending, if: ->(build) do - build.scheduled_at&.past? && !build.any_unmet_prerequisites? - end + before_transition on: :enqueue_scheduled do |build| + build.scheduled_at.nil? || build.scheduled_at.past? # If false is returned, it stops the transition end before_transition scheduled: any do |build| @@ -237,6 +248,27 @@ module Ci build.scheduled_at = build.options_scheduled_at end + before_transition any => :waiting_for_resource do |build| + build.waiting_for_resource_at = Time.now + end + + before_transition on: :enqueue_waiting_for_resource do |build| + next unless build.requires_resource? + + build.resource_group.assign_resource_to(build) # If false is returned, it stops the transition + end + + after_transition any => :waiting_for_resource do |build| + build.run_after_commit do + Ci::ResourceGroups::AssignResourceFromResourceGroupWorker + .perform_async(build.resource_group_id) + end + end + + before_transition on: :enqueue_preparing do |build| + !build.any_unmet_prerequisites? # If false is returned, it stops the transition + end + after_transition created: :scheduled do |build| build.run_after_commit do Ci::BuildScheduleWorker.perform_at(build.scheduled_at, build.id) @@ -265,6 +297,16 @@ module Ci end end + after_transition any => ::Ci::Build.completed_statuses do |build| + next unless build.resource_group_id.present? + next unless build.resource_group.release_resource_from(build) + + build.run_after_commit do + Ci::ResourceGroups::AssignResourceFromResourceGroupWorker + .perform_async(build.resource_group_id) + end + end + after_transition any => [:success, :failed, :canceled] do |build| build.run_after_commit do BuildFinishedWorker.perform_async(id) @@ -405,10 +447,6 @@ module Ci options_retry_when.include?('always') end - def latest? - !retried? - end - def any_unmet_prerequisites? prerequisites.present? end @@ -437,6 +475,11 @@ module Ci end end + def requires_resource? + Feature.enabled?(:ci_resource_group, project, default_enabled: true) && + self.resource_group_id.present? + end + def has_environment? environment.present? end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 62bf2c3ac9c..9eca324f0fc 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -16,6 +16,8 @@ module Ci archive: nil, metadata: nil, trace: nil, + metrics_referee: nil, + network_referee: nil, junit: 'junit.xml', codequality: 'gl-code-quality-report.json', sast: 'gl-sast-report.json', @@ -23,6 +25,7 @@ module Ci container_scanning: 'gl-container-scanning-report.json', dast: 'gl-dast-report.json', license_management: 'gl-license-management-report.json', + license_scanning: 'gl-license-scanning-report.json', performance: 'performance.json', metrics: 'metrics.txt' }.freeze @@ -36,6 +39,8 @@ module Ci REPORT_TYPES = { junit: :gzip, metrics: :gzip, + metrics_referee: :gzip, + network_referee: :gzip, # All these file formats use `raw` as we need to store them uncompressed # for Frontend to fetch the files and do analysis @@ -46,6 +51,7 @@ module Ci container_scanning: :raw, dast: :raw, license_management: :raw, + license_scanning: :raw, performance: :raw }.freeze @@ -104,8 +110,11 @@ module Ci dast: 8, ## EE-specific codequality: 9, ## EE-specific license_management: 10, ## EE-specific + license_scanning: 101, ## EE-specific till 13.0 performance: 11, ## EE-specific - metrics: 12 ## EE-specific + metrics: 12, ## EE-specific + metrics_referee: 13, ## runner referees + network_referee: 14 ## runner referees } enum file_format: { diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 3710a0b914e..7e3ba98d86c 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -26,15 +26,14 @@ module Ci belongs_to :merge_request, class_name: 'MergeRequest' belongs_to :external_pull_request - has_internal_id :iid, scope: :project, presence: false, ensure_if: -> { !importing? }, init: ->(s) do + has_internal_id :iid, scope: :project, presence: false, track_if: -> { !importing? }, ensure_if: -> { !importing? }, init: ->(s) do s&.project&.all_pipelines&.maximum(:iid) || s&.project&.all_pipelines&.count end has_many :stages, -> { order(position: :asc) }, inverse_of: :pipeline has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline has_many :latest_statuses_ordered_by_stage, -> { latest.order(:stage_idx, :stage) }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline - has_many :processables, -> { processables }, - class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline + has_many :processables, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id # rubocop:disable Cop/ActiveRecordDependent has_many :variables, class_name: 'Ci::PipelineVariable' @@ -61,9 +60,13 @@ module Ci has_one :chat_data, class_name: 'Ci::PipelineChatData' has_many :triggered_pipelines, through: :sourced_pipelines, source: :pipeline + has_many :child_pipelines, -> { merge(Ci::Sources::Pipeline.same_project) }, through: :sourced_pipelines, source: :pipeline has_one :triggered_by_pipeline, through: :source_pipeline, source: :source_pipeline + has_one :parent_pipeline, -> { merge(Ci::Sources::Pipeline.same_project) }, through: :source_pipeline, source: :source_pipeline has_one :source_job, through: :source_pipeline, source: :source_job + has_one :pipeline_config, class_name: 'Ci::PipelineConfig', inverse_of: :pipeline + accepts_nested_attributes_for :variables, reject_if: :persisted? delegate :id, to: :project, prefix: true @@ -97,10 +100,14 @@ module Ci state_machine :status, initial: :created do event :enqueue do - transition [:created, :preparing, :skipped, :scheduled] => :pending + transition [:created, :waiting_for_resource, :preparing, :skipped, :scheduled] => :pending transition [:success, :failed, :canceled] => :running end + event :request_resource do + transition any - [:waiting_for_resource] => :waiting_for_resource + end + event :prepare do transition any - [:preparing] => :preparing end @@ -137,7 +144,7 @@ module Ci # Do not add any operations to this state_machine # Create a separate worker for each new operation - before_transition [:created, :preparing, :pending] => :running do |pipeline| + before_transition [:created, :waiting_for_resource, :preparing, :pending] => :running do |pipeline| pipeline.started_at = Time.now end @@ -160,7 +167,7 @@ module Ci end end - after_transition [:created, :preparing, :pending] => :running do |pipeline| + after_transition [:created, :waiting_for_resource, :preparing, :pending] => :running do |pipeline| pipeline.run_after_commit { PipelineMetricsWorker.perform_async(pipeline.id) } end @@ -168,7 +175,7 @@ module Ci pipeline.run_after_commit { PipelineMetricsWorker.perform_async(pipeline.id) } end - after_transition [:created, :preparing, :pending, :running] => :success do |pipeline| + after_transition [:created, :waiting_for_resource, :preparing, :pending, :running] => :success do |pipeline| pipeline.run_after_commit { PipelineSuccessWorker.perform_async(pipeline.id) } end @@ -190,6 +197,10 @@ module Ci AutoMergeProcessWorker.perform_async(merge_request.id) end + + if pipeline.auto_devops_source? + self.class.auto_devops_pipelines_completed_total.increment(status: pipeline.status) + end end end @@ -207,6 +218,7 @@ module Ci end scope :internal, -> { where(source: internal_sources) } + scope :no_child, -> { where.not(source: :parent_pipeline) } scope :ci_sources, -> { where(config_source: ::Ci::PipelineEnums.ci_config_sources_values) } scope :for_user, -> (user) { where(user: user) } scope :for_sha, -> (sha) { where(sha: sha) } @@ -319,7 +331,11 @@ module Ci end def self.bridgeable_statuses - ::Ci::Pipeline::AVAILABLE_STATUSES - %w[created preparing pending] + ::Ci::Pipeline::AVAILABLE_STATUSES - %w[created waiting_for_resource preparing pending] + end + + def self.auto_devops_pipelines_completed_total + @auto_devops_pipelines_completed_total ||= Gitlab::Metrics.counter(:auto_devops_pipelines_completed_total, 'Number of completed auto devops pipelines') end def stages_count @@ -499,11 +515,9 @@ module Ci # rubocop: enable CodeReuse/ServiceClass def mark_as_processable_after_stage(stage_idx) - builds.skipped.after_stage(stage_idx).find_each(&:process) - end - - def child? - false + builds.skipped.after_stage(stage_idx).find_each do |build| + Gitlab::OptimisticLocking.retry_lock(build, &:process) + end end def latest? @@ -542,6 +556,13 @@ module Ci end end + def needs_processing? + statuses + .where(processed: [false, nil]) + .latest + .exists? + end + # TODO: this logic is duplicate with Pipeline::Chain::Config::Content # we should persist this is `ci_pipelines.config_path` def config_path @@ -571,11 +592,11 @@ module Ci project.notes.for_commit_id(sha) end - def update_status + def set_status(new_status) retry_optimistic_lock(self) do - new_status = latest_builds_status.to_s case new_status when 'created' then nil + when 'waiting_for_resource' then request_resource when 'preparing' then prepare when 'pending' then enqueue when 'running' then run @@ -592,6 +613,10 @@ module Ci end end + def update_legacy_status + set_status(latest_builds_status.to_s) + end + def protected_ref? strong_memoize(:protected_ref) { project.protected_for?(git_ref) } end @@ -687,6 +712,24 @@ module Ci all_merge_requests.order(id: :desc) end + # If pipeline is a child of another pipeline, include the parent + # and the siblings, otherwise return only itself. + def same_family_pipeline_ids + if (parent = parent_pipeline) + [parent.id] + parent.child_pipelines.pluck(:id) + else + [self.id] + end + end + + def child? + parent_pipeline.present? + end + + def parent? + child_pipelines.exists? + end + def detailed_status(current_user) Gitlab::Ci::Status::Pipeline::Factory .new(self, current_user) diff --git a/app/models/ci/pipeline_config.rb b/app/models/ci/pipeline_config.rb new file mode 100644 index 00000000000..d5a8da2bc1e --- /dev/null +++ b/app/models/ci/pipeline_config.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Ci + class PipelineConfig < ApplicationRecord + extend Gitlab::Ci::Model + + self.table_name = 'ci_pipelines_config' + self.primary_key = :pipeline_id + + belongs_to :pipeline, class_name: "Ci::Pipeline", inverse_of: :pipeline_config + validates :pipeline, presence: true + validates :content, presence: true + end +end diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb index 3cd88807969..fde169d2f03 100644 --- a/app/models/ci/pipeline_enums.rb +++ b/app/models/ci/pipeline_enums.rb @@ -23,10 +23,13 @@ module Ci schedule: 4, api: 5, external: 6, + # TODO: Rename `pipeline` to `cross_project_pipeline` in 13.0 + # https://gitlab.com/gitlab-org/gitlab/issues/195991 pipeline: 7, chat: 8, merge_request_event: 10, - external_pull_request_event: 11 + external_pull_request_event: 11, + parent_pipeline: 12 } end @@ -38,7 +41,8 @@ module Ci repository_source: 1, auto_devops_source: 2, remote_source: 4, - external_project_source: 5 + external_project_source: 5, + bridge_source: 6 } end diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb index 946241b7d4c..9a1445e624c 100644 --- a/app/models/ci/pipeline_schedule.rb +++ b/app/models/ci/pipeline_schedule.rb @@ -5,6 +5,7 @@ module Ci extend Gitlab::Ci::Model include Importable include StripAttribute + include Schedulable belongs_to :project belongs_to :owner, class_name: 'User' @@ -18,13 +19,10 @@ module Ci validates :description, presence: true validates :variables, variable_duplicates: true - before_save :set_next_run_at - strip_attributes :cron scope :active, -> { where(active: true) } scope :inactive, -> { where(active: false) } - scope :runnable_schedules, -> { active.where("next_run_at < ?", Time.now) } scope :preloaded, -> { preload(:owner, :project) } accepts_nested_attributes_for :variables, allow_destroy: true @@ -62,12 +60,6 @@ module Ci end end - def schedule_next_run! - save! # with set_next_run_at - rescue ActiveRecord::RecordInvalid - update_column(:next_run_at, nil) # update without validation - end - def job_variables variables&.map(&:to_runner_variable) || [] end diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb new file mode 100644 index 00000000000..6c4b271cd2c --- /dev/null +++ b/app/models/ci/processable.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Ci + class Processable < ::CommitStatus + has_many :needs, class_name: 'Ci::BuildNeed', foreign_key: :build_id, inverse_of: :build + + accepts_nested_attributes_for :needs + + scope :preload_needs, -> { preload(:needs) } + + def self.select_with_aggregated_needs(project) + return all unless Feature.enabled?(:ci_dag_support, project, default_enabled: true) + + aggregated_needs_names = Ci::BuildNeed + .scoped_build + .select("ARRAY_AGG(name)") + .to_sql + + all.select( + '*', + "(#{aggregated_needs_names}) as aggregated_needs_names" + ) + end + + validates :type, presence: true + + def aggregated_needs_names + read_attribute(:aggregated_needs_names) + end + + def schedulable? + raise NotImplementedError + end + + def action? + raise NotImplementedError + end + + def when + read_attribute(:when) || 'on_success' + end + + def expanded_environment_name + raise NotImplementedError + end + + def scoped_variables_hash + raise NotImplementedError + end + end +end diff --git a/app/models/ci/resource.rb b/app/models/ci/resource.rb new file mode 100644 index 00000000000..ee5b6546165 --- /dev/null +++ b/app/models/ci/resource.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Ci + class Resource < ApplicationRecord + extend Gitlab::Ci::Model + + belongs_to :resource_group, class_name: 'Ci::ResourceGroup', inverse_of: :resources + belongs_to :build, class_name: 'Ci::Build', inverse_of: :resource + + scope :free, -> { where(build: nil) } + scope :retained_by, -> (build) { where(build: build) } + end +end diff --git a/app/models/ci/resource_group.rb b/app/models/ci/resource_group.rb new file mode 100644 index 00000000000..eb18f3da0bf --- /dev/null +++ b/app/models/ci/resource_group.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Ci + class ResourceGroup < ApplicationRecord + extend Gitlab::Ci::Model + + belongs_to :project, inverse_of: :resource_groups + + has_many :resources, class_name: 'Ci::Resource', inverse_of: :resource_group + has_many :builds, class_name: 'Ci::Build', inverse_of: :resource_group + + validates :key, + length: { maximum: 255 }, + format: { with: Gitlab::Regex.environment_name_regex, + message: Gitlab::Regex.environment_name_regex_message } + + before_create :ensure_resource + + ## + # NOTE: This is concurrency-safe method that the subquery in the `UPDATE` + # works as explicit locking. + def assign_resource_to(build) + resources.free.limit(1).update_all(build_id: build.id) > 0 + end + + def release_resource_from(build) + resources.retained_by(build).update_all(build_id: nil) > 0 + end + + private + + def ensure_resource + # Currently we only support one resource per group, which means + # maximum one build can be set to the resource group, thus builds + # belong to the same resource group are executed once at time. + self.resources.build if self.resources.empty? + end + end +end diff --git a/app/models/ci/sources/pipeline.rb b/app/models/ci/sources/pipeline.rb index feaec27281c..d71e3b55b9a 100644 --- a/app/models/ci/sources/pipeline.rb +++ b/app/models/ci/sources/pipeline.rb @@ -18,6 +18,8 @@ module Ci validates :source_project, presence: true validates :source_job, presence: true validates :source_pipeline, presence: true + + scope :same_project, -> { where(arel_table[:source_project_id].eq(arel_table[:project_id])) } end end end diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index 77ac8bfe875..75f73429c2a 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -13,9 +13,12 @@ module Ci belongs_to :pipeline has_many :statuses, class_name: 'CommitStatus', foreign_key: :stage_id + has_many :processables, class_name: 'Ci::Processable', foreign_key: :stage_id has_many :builds, foreign_key: :stage_id has_many :bridges, foreign_key: :stage_id + scope :ordered, -> { order(position: :asc) } + with_options unless: :importing? do validates :project, presence: true validates :pipeline, presence: true @@ -39,10 +42,14 @@ module Ci state_machine :status, initial: :created do event :enqueue do - transition [:created, :preparing] => :pending + transition [:created, :waiting_for_resource, :preparing] => :pending transition [:success, :failed, :canceled, :skipped] => :running end + event :request_resource do + transition any - [:waiting_for_resource] => :waiting_for_resource + end + event :prepare do transition any - [:preparing] => :preparing end @@ -76,11 +83,11 @@ module Ci end end - def update_status + def set_status(new_status) retry_optimistic_lock(self) do - new_status = latest_stage_status.to_s case new_status when 'created' then nil + when 'waiting_for_resource' then request_resource when 'preparing' then prepare when 'pending' then enqueue when 'running' then run @@ -97,6 +104,10 @@ module Ci end end + def update_legacy_status + set_status(latest_stage_status.to_s) + end + def groups @groups ||= Ci::Group.fabricate(self) end diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index 68548bd2fdc..85cb3f5b46a 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -11,7 +11,7 @@ module Ci has_many :trigger_requests validates :token, presence: true, uniqueness: true - validates :owner, presence: true, unless: :supports_legacy_tokens? + validates :owner, presence: true before_validation :set_default_values @@ -31,17 +31,8 @@ module Ci token[0...4] if token.present? end - def legacy? - self.owner_id.blank? - end - - def supports_legacy_tokens? - Feature.enabled?(:use_legacy_pipeline_triggers, project) - end - def can_access_project? - supports_legacy_tokens? && legacy? || - Ability.allowed?(self.owner, :create_build, project) + Ability.allowed?(self.owner, :create_build, project) end end end diff --git a/app/models/clusters/applications/elastic_stack.rb b/app/models/clusters/applications/elastic_stack.rb index 9854ad2ea3e..e86a4597ed8 100644 --- a/app/models/clusters/applications/elastic_stack.rb +++ b/app/models/clusters/applications/elastic_stack.rb @@ -15,24 +15,15 @@ module Clusters include ::Clusters::Concerns::ApplicationData include ::Gitlab::Utils::StrongMemoize - default_value_for :version, VERSION - - def set_initial_status - return unless not_installable? - return unless cluster&.application_ingress_available? + include IgnorableColumns + ignore_column :kibana_hostname, remove_with: '12.8', remove_after: '2020-01-22' - ingress = cluster.application_ingress - self.status = status_states[:installable] if ingress.external_ip_or_hostname? - end + default_value_for :version, VERSION def chart 'stable/elastic-stack' end - def values - content_values.to_yaml - end - def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( name: 'elastic-stack', @@ -78,24 +69,6 @@ module Clusters private - def specification - { - "kibana" => { - "ingress" => { - "hosts" => [kibana_hostname], - "tls" => [{ - "hosts" => [kibana_hostname], - "secretName" => "kibana-cert" - }] - } - } - } - end - - def content_values - YAML.load_file(chart_values_file).deep_merge!(specification) - end - def post_delete_script [ Gitlab::Kubernetes::KubectlCmd.delete("pvc", "--selector", "release=elastic-stack") diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb index d140649af3c..63f216c7af5 100644 --- a/app/models/clusters/applications/ingress.rb +++ b/app/models/clusters/applications/ingress.rb @@ -14,6 +14,7 @@ module Clusters include AfterCommitQueue default_value_for :ingress_type, :nginx + default_value_for :modsecurity_enabled, false default_value_for :version, VERSION enum ingress_type: { @@ -41,7 +42,7 @@ module Clusters end def allowed_to_uninstall? - external_ip_or_hostname? && application_jupyter_nil_or_installable? && application_elastic_stack_nil_or_installable? + external_ip_or_hostname? && application_jupyter_nil_or_installable? end def install_command @@ -73,7 +74,7 @@ module Clusters private def specification - return {} unless Feature.enabled?(:ingress_modsecurity) + return {} unless modsecurity_enabled { "controller" => { @@ -154,10 +155,6 @@ module Clusters def application_jupyter_nil_or_installable? cluster.application_jupyter.nil? || cluster.application_jupyter&.installable? end - - def application_elastic_stack_nil_or_installable? - cluster.application_elastic_stack.nil? || cluster.application_elastic_stack&.installable? - end end end end diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb index ca93bc15be0..42fa4a6f179 100644 --- a/app/models/clusters/applications/jupyter.rb +++ b/app/models/clusters/applications/jupyter.rb @@ -5,7 +5,7 @@ require 'securerandom' module Clusters module Applications class Jupyter < ApplicationRecord - VERSION = '0.9-174bbd5' + VERSION = '0.9.0-beta.2' self.table_name = 'clusters_applications_jupyter' diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index 4ac33d4e3be..d24a298b0a6 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -5,7 +5,7 @@ module Clusters class Prometheus < ApplicationRecord include PrometheusAdapter - VERSION = '6.7.3' + VERSION = '9.5.2' self.table_name = 'clusters_applications_prometheus' @@ -90,7 +90,7 @@ module Clusters # ensures headers containing auth data are appended to original k8s client options options = kube_client.rest_client.options.merge(headers: kube_client.headers) Gitlab::PrometheusClient.new(proxy_url, options) - rescue Kubeclient::HttpError, Errno::ECONNRESET, Errno::ECONNREFUSED + rescue Kubeclient::HttpError, Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::ENETUNREACH # If users have mistakenly set parameters or removed the depended clusters, # `proxy_url` could raise an exception because gitlab can not communicate with the cluster. # Since `PrometheusAdapter#can_query?` is eargely loaded on environement pages in gitlab, diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index fd05fd6bab9..a908ca28188 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.11.0' + VERSION = '0.12.0' self.table_name = 'clusters_applications_runners' diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb index f6431f5bac3..b94f2b15846 100644 --- a/app/models/clusters/concerns/application_core.rb +++ b/app/models/clusters/concerns/application_core.rb @@ -15,7 +15,7 @@ module Clusters def set_initial_status return unless not_installable? - self.status = status_states[:installable] if cluster&.application_helm_available? + self.status = status_states[:installable] if cluster&.application_helm_available? || Feature.enabled?(:managed_apps_local_tiller) end def can_uninstall? diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 8d38835fb3b..f9101609f89 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -40,12 +40,12 @@ class CommitStatus < ApplicationRecord scope :latest, -> { where(retried: [false, nil]) } scope :retried, -> { where(retried: true) } scope :ordered, -> { order(:name) } + scope :ordered_by_stage, -> { order(stage_idx: :asc) } scope :latest_ordered, -> { latest.ordered.includes(project: :namespace) } scope :retried_ordered, -> { retried.ordered.includes(project: :namespace) } scope :before_stage, -> (index) { where('stage_idx < ?', index) } scope :for_stage, -> (index) { where(stage_idx: index) } scope :after_stage, -> (index) { where('stage_idx > ?', index) } - scope :processables, -> { where(type: %w[Ci::Build Ci::Bridge]) } scope :for_ids, -> (ids) { where(id: ids) } scope :for_ref, -> (ref) { where(ref: ref) } scope :by_name, -> (name) { where(name: name) } @@ -58,6 +58,10 @@ class CommitStatus < ApplicationRecord preload(:project, :user) end + scope :with_project_preload, -> do + preload(project: :namespace) + end + scope :with_needs, -> (names = nil) do needs = Ci::BuildNeed.scoped_build.select(1) needs = needs.where(name: names) if names @@ -70,6 +74,15 @@ class CommitStatus < ApplicationRecord where('NOT EXISTS (?)', needs) end + scope :match_id_and_lock_version, -> (slice) do + # it expects that items are an array of attributes to match + # each hash needs to have `id` and `lock_version` + slice.inject(self) do |relation, item| + match = CommitStatus.where(item.slice(:id, :lock_version)) + relation.or(match) + end + end + # We use `CommitStatusEnums.failure_reasons` here so that EE can more easily # extend this `Hash` with new values. enum_with_nil failure_reason: ::CommitStatusEnums.failure_reasons @@ -87,6 +100,16 @@ class CommitStatus < ApplicationRecord # rubocop: enable CodeReuse/ServiceClass end + before_save if: :status_changed?, unless: :importing? do + if Feature.disabled?(:ci_atomic_processing, project) + self.processed = nil + elsif latest? + self.processed = false # force refresh of all dependent ones + elsif retried? + self.processed = true # retried are considered to be already processed + end + end + state_machine :status do event :process do transition [:skipped, :manual] => :created @@ -96,7 +119,7 @@ class CommitStatus < ApplicationRecord # A CommitStatus will never have prerequisites, but this event # is shared by Ci::Build, which cannot progress unless prerequisites # are satisfied. - transition [:created, :preparing, :skipped, :manual, :scheduled] => :pending, unless: :any_unmet_prerequisites? + transition [:created, :skipped, :manual, :scheduled] => :pending, if: :all_met_to_become_pending? end event :run do @@ -104,22 +127,22 @@ class CommitStatus < ApplicationRecord end event :skip do - transition [:created, :preparing, :pending] => :skipped + transition [:created, :waiting_for_resource, :preparing, :pending] => :skipped end event :drop do - transition [:created, :preparing, :pending, :running, :scheduled] => :failed + transition [:created, :waiting_for_resource, :preparing, :pending, :running, :scheduled] => :failed end event :success do - transition [:created, :preparing, :pending, :running] => :success + transition [:created, :waiting_for_resource, :preparing, :pending, :running] => :success end event :cancel do - transition [:created, :preparing, :pending, :running, :manual, :scheduled] => :canceled + transition [:created, :waiting_for_resource, :preparing, :pending, :running, :manual, :scheduled] => :canceled end - before_transition [:created, :preparing, :skipped, :manual, :scheduled] => :pending do |commit_status| + before_transition [:created, :waiting_for_resource, :preparing, :skipped, :manual, :scheduled] => :pending do |commit_status| commit_status.queued_at = Time.now end @@ -137,19 +160,13 @@ class CommitStatus < ApplicationRecord end after_transition do |commit_status, transition| - next unless commit_status.project next if transition.loopback? + next if commit_status.processed? + next unless commit_status.project commit_status.run_after_commit do - if pipeline_id - if complete? || manual? - PipelineProcessWorker.perform_async(pipeline_id, [id]) - else - PipelineUpdateWorker.perform_async(pipeline_id) - end - end - - StageUpdateWorker.perform_async(stage_id) + schedule_stage_and_pipeline_update + ExpireJobCacheWorker.perform_async(id) end end @@ -178,6 +195,11 @@ class CommitStatus < ApplicationRecord where(name: names).latest.slow_composite_status || 'success' end + def self.update_as_processed! + # Marks items as processed, and increases `lock_version` (Optimisitc Locking) + update_all('processed=TRUE, lock_version=COALESCE(lock_version,0)+1') + end + def locking_enabled? will_save_change_to_status? end @@ -194,6 +216,10 @@ class CommitStatus < ApplicationRecord calculate_duration end + def latest? + !retried? + end + def playable? false end @@ -218,10 +244,18 @@ class CommitStatus < ApplicationRecord false end + def all_met_to_become_pending? + !any_unmet_prerequisites? && !requires_resource? + end + def any_unmet_prerequisites? false end + def requires_resource? + false + end + def auto_canceled? canceled? && auto_canceled_by_id? end @@ -237,4 +271,21 @@ class CommitStatus < ApplicationRecord v =~ /\d+/ ? v.to_i : v end end + + private + + def schedule_stage_and_pipeline_update + if Feature.enabled?(:ci_atomic_processing, project) + # Atomic Processing requires only single Worker + PipelineProcessWorker.perform_async(pipeline_id, [id]) + else + if complete? || manual? + PipelineProcessWorker.perform_async(pipeline_id, [id]) + else + PipelineUpdateWorker.perform_async(pipeline_id) + end + + StageUpdateWorker.perform_async(stage_id) + end + end end diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb index 64df265dc25..3e9b084e784 100644 --- a/app/models/concerns/atomic_internal_id.rb +++ b/app/models/concerns/atomic_internal_id.rb @@ -27,13 +27,13 @@ module AtomicInternalId extend ActiveSupport::Concern class_methods do - def has_internal_id(column, scope:, init:, ensure_if: nil, presence: true) # rubocop:disable Naming/PredicateName + def has_internal_id(column, scope:, init:, ensure_if: nil, track_if: nil, presence: true) # rubocop:disable Naming/PredicateName # We require init here to retain the ability to recalculate in the absence of a - # InternaLId record (we may delete records in `internal_ids` for example). + # InternalId record (we may delete records in `internal_ids` for example). raise "has_internal_id requires a init block, none given." unless init raise "has_internal_id needs to be defined on association." unless self.reflect_on_association(scope) - before_validation :"track_#{scope}_#{column}!", on: :create + before_validation :"track_#{scope}_#{column}!", on: :create, if: track_if before_validation :"ensure_#{scope}_#{column}!", on: :create, if: ensure_if validates column, presence: presence diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb index f229b42ade6..0f2a389f0a3 100644 --- a/app/models/concerns/awardable.rb +++ b/app/models/concerns/awardable.rb @@ -67,7 +67,9 @@ module Awardable ) ).join_sources - joins(join_clause).group(awardable_table[:id]).reorder("COUNT(award_emoji.id) #{direction}") + joins(join_clause).group(awardable_table[:id]).reorder( + Arel.sql("COUNT(award_emoji.id) #{direction}") + ) end end diff --git a/app/models/concerns/ci/processable.rb b/app/models/concerns/ci/processable.rb deleted file mode 100644 index c229358ad17..00000000000 --- a/app/models/concerns/ci/processable.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -module Ci - ## - # This module implements methods that need to be implemented by CI/CD - # entities that are supposed to go through pipeline processing - # services. - # - # - module Processable - extend ActiveSupport::Concern - - included do - has_many :needs, class_name: 'Ci::BuildNeed', foreign_key: :build_id, inverse_of: :build - - accepts_nested_attributes_for :needs - - scope :preload_needs, -> { preload(:needs) } - end - - def schedulable? - raise NotImplementedError - end - - def action? - raise NotImplementedError - end - - def when - read_attribute(:when) || 'on_success' - end - - def expanded_environment_name - raise NotImplementedError - end - - def scoped_variables_hash - raise NotImplementedError - end - end -end diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb index c01fb4740e5..e06dad38c32 100644 --- a/app/models/concerns/has_status.rb +++ b/app/models/concerns/has_status.rb @@ -5,16 +5,16 @@ module HasStatus DEFAULT_STATUS = 'created' BLOCKED_STATUS = %w[manual scheduled].freeze - AVAILABLE_STATUSES = %w[created preparing pending running success failed canceled skipped manual scheduled].freeze + AVAILABLE_STATUSES = %w[created waiting_for_resource preparing pending running success failed canceled skipped manual scheduled].freeze STARTED_STATUSES = %w[running success failed skipped manual scheduled].freeze - ACTIVE_STATUSES = %w[preparing pending running].freeze + ACTIVE_STATUSES = %w[waiting_for_resource preparing pending running].freeze COMPLETED_STATUSES = %w[success failed canceled skipped].freeze - ORDERED_STATUSES = %w[failed preparing pending running manual scheduled canceled success skipped created].freeze + ORDERED_STATUSES = %w[failed preparing pending running waiting_for_resource manual scheduled canceled success skipped created].freeze PASSED_WITH_WARNINGS_STATUSES = %w[failed canceled].to_set.freeze EXCLUDE_IGNORED_STATUSES = %w[manual failed canceled].to_set.freeze STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3, failed: 4, canceled: 5, skipped: 6, manual: 7, - scheduled: 8, preparing: 9 }.freeze + scheduled: 8, preparing: 9, waiting_for_resource: 10 }.freeze UnknownStatusError = Class.new(StandardError) @@ -29,6 +29,7 @@ module HasStatus manual = scope_relevant.manual.select('count(*)').to_sql scheduled = scope_relevant.scheduled.select('count(*)').to_sql preparing = scope_relevant.preparing.select('count(*)').to_sql + waiting_for_resource = scope_relevant.waiting_for_resource.select('count(*)').to_sql pending = scope_relevant.pending.select('count(*)').to_sql running = scope_relevant.running.select('count(*)').to_sql skipped = scope_relevant.skipped.select('count(*)').to_sql @@ -46,6 +47,7 @@ module HasStatus WHEN (#{builds})=(#{success})+(#{skipped})+(#{canceled}) THEN 'canceled' WHEN (#{builds})=(#{created})+(#{skipped})+(#{pending}) THEN 'pending' WHEN (#{running})+(#{pending})>0 THEN 'running' + WHEN (#{waiting_for_resource})>0 THEN 'waiting_for_resource' WHEN (#{manual})>0 THEN 'manual' WHEN (#{scheduled})>0 THEN 'scheduled' WHEN (#{preparing})>0 THEN 'preparing' @@ -95,6 +97,7 @@ module HasStatus state_machine :status, initial: :created do state :created, value: 'created' + state :waiting_for_resource, value: 'waiting_for_resource' state :preparing, value: 'preparing' state :pending, value: 'pending' state :running, value: 'running' @@ -107,6 +110,7 @@ module HasStatus end scope :created, -> { with_status(:created) } + scope :waiting_for_resource, -> { with_status(:waiting_for_resource) } scope :preparing, -> { with_status(:preparing) } scope :relevant, -> { without_status(:created) } scope :running, -> { with_status(:running) } @@ -117,8 +121,8 @@ module HasStatus scope :skipped, -> { with_status(:skipped) } scope :manual, -> { with_status(:manual) } scope :scheduled, -> { with_status(:scheduled) } - scope :alive, -> { with_status(:created, :preparing, :pending, :running) } - scope :alive_or_scheduled, -> { with_status(:created, :preparing, :pending, :running, :scheduled) } + scope :alive, -> { with_status(:created, :waiting_for_resource, :preparing, :pending, :running) } + scope :alive_or_scheduled, -> { with_status(:created, :waiting_for_resource, :preparing, :pending, :running, :scheduled) } scope :created_or_pending, -> { with_status(:created, :pending) } scope :running_or_pending, -> { with_status(:running, :pending) } scope :finished, -> { with_status(:success, :failed, :canceled) } @@ -126,7 +130,7 @@ module HasStatus scope :incomplete, -> { without_statuses(completed_statuses) } scope :cancelable, -> do - where(status: [:running, :preparing, :pending, :created, :scheduled]) + where(status: [:running, :waiting_for_resource, :preparing, :pending, :created, :scheduled]) end scope :without_statuses, -> (names) do diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 9e3fba139e3..fe0fad4b9d5 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -13,6 +13,7 @@ module Issuable include CacheMarkdownField include Participable include Mentionable + include Milestoneable include Subscribable include StripAttribute include Awardable @@ -56,7 +57,6 @@ module Issuable belongs_to :author, class_name: 'User' belongs_to :updated_by, class_name: 'User' belongs_to :last_edited_by, class_name: 'User' - belongs_to :milestone has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do # rubocop:disable Cop/ActiveRecordDependent def authors_loaded? @@ -89,18 +89,12 @@ module Issuable # to avoid breaking the existing Issuables which may have their descriptions longer validates :description, length: { maximum: DESCRIPTION_LENGTH_MAX }, allow_blank: true, on: :create validate :description_max_length_for_new_records_is_valid, on: :update - validate :milestone_is_valid before_validation :truncate_description_on_import! scope :authored, ->(user) { where(author_id: user) } scope :recent, -> { reorder(id: :desc) } scope :of_projects, ->(ids) { where(project_id: ids) } - scope :of_milestones, ->(ids) { where(milestone_id: ids) } - scope :any_milestone, -> { where('milestone_id IS NOT NULL') } - scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) } - scope :any_release, -> { joins_milestone_releases } - scope :with_release, -> (tag, project_id) { joins_milestone_releases.where( milestones: { releases: { tag: tag, project_id: project_id } } ) } scope :opened, -> { with_state(:opened) } scope :only_opened, -> { with_state(:opened) } scope :closed, -> { with_state(:closed) } @@ -118,20 +112,6 @@ module Issuable end # rubocop:enable GitlabSecurity/SqlInjection - scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") } - scope :order_milestone_due_desc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC')) } - scope :order_milestone_due_asc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC')) } - - scope :without_release, -> do - joins("LEFT OUTER JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id") - .where('milestone_releases.release_id IS NULL') - end - - scope :joins_milestone_releases, -> do - joins("JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id - JOIN releases ON milestone_releases.release_id = releases.id").distinct - end - scope :without_label, -> { joins("LEFT OUTER JOIN label_links ON label_links.target_type = '#{name}' AND label_links.target_id = #{table_name}.id").where(label_links: { id: nil }) } scope :any_label, -> { joins(:label_links).group(:id) } scope :join_project, -> { joins(:project) } @@ -164,10 +144,6 @@ module Issuable private - def milestone_is_valid - errors.add(:milestone_id, message: "is invalid") if respond_to?(:milestone_id) && milestone_id.present? && !milestone_available? - end - def description_max_length_for_new_records_is_valid if new_record? && description.length > Issuable::DESCRIPTION_LENGTH_MAX errors.add(:description, :too_long, count: Issuable::DESCRIPTION_LENGTH_MAX) @@ -332,10 +308,6 @@ module Issuable project end - def milestone_available? - project_id == milestone&.project_id || project.ancestors_upto.compact.include?(milestone&.group) - end - def assignee_or_author?(user) author_id == user.id || assignees.exists?(user.id) end @@ -482,13 +454,6 @@ module Issuable def wipless_title_changed(old_title) old_title != title end - - ## - # Overridden on EE module - # - def supports_milestone? - respond_to?(:milestone_id) - end end Issuable.prepend_if_ee('EE::Issuable') # rubocop: disable Cop/InjectEnterpriseEditionModule diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb new file mode 100644 index 00000000000..7fb3f95bf0a --- /dev/null +++ b/app/models/concerns/milestoneable.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# == Milestoneable concern +# +# Contains functionality related to objects that can be assigned Milestones +# +# Used by Issuable +# +module Milestoneable + extend ActiveSupport::Concern + + included do + belongs_to :milestone + + validate :milestone_is_valid + + after_save :write_to_new_milestone_relationship + + scope :of_milestones, ->(ids) { where(milestone_id: ids) } + scope :any_milestone, -> { where('milestone_id IS NOT NULL') } + scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) } + scope :any_release, -> { joins_milestone_releases } + scope :with_release, -> (tag, project_id) { joins_milestone_releases.where( milestones: { releases: { tag: tag, project_id: project_id } } ) } + + scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") } + scope :order_milestone_due_desc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC')) } + scope :order_milestone_due_asc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC')) } + + scope :without_release, -> do + joins("LEFT OUTER JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id") + .where('milestone_releases.release_id IS NULL') + end + + scope :joins_milestone_releases, -> do + joins("JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id + JOIN releases ON milestone_releases.release_id = releases.id").distinct + end + + private + + def milestone_is_valid + errors.add(:milestone_id, message: "is invalid") if respond_to?(:milestone_id) && milestone_id.present? && !milestone_available? + end + + def write_to_new_milestone_relationship + self.milestones = [milestone].compact if supports_milestone? && saved_change_to_milestone_id? + end + end + + def milestone_available? + project_id == milestone&.project_id || project.ancestors_upto.compact.include?(milestone&.group) + end + + ## + # Overridden on EE module + # + def supports_milestone? + respond_to?(:milestone_id) + end +end + +Milestoneable.prepend_if_ee('EE::Milestoneable') diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb index 551a2e56ecf..eac676f30a5 100644 --- a/app/models/concerns/project_features_compatibility.rb +++ b/app/models/concerns/project_features_compatibility.rb @@ -50,6 +50,10 @@ module ProjectFeaturesCompatibility write_feature_attribute_string(:merge_requests_access_level, value) end + def forking_access_level=(value) + write_feature_attribute_string(:forking_access_level, value) + end + def issues_access_level=(value) write_feature_attribute_string(:issues_access_level, value) end diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb index d9a7f0a96dc..cddca72f91f 100644 --- a/app/models/concerns/protected_ref.rb +++ b/app/models/concerns/protected_ref.rb @@ -10,6 +10,8 @@ module ProtectedRef validates :project, presence: true delegate :matching, :matches?, :wildcard?, to: :ref_matcher + + scope :for_project, ->(project) { where(project: project) } end def commit diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb index 693f9ab8dc5..4b9896343c6 100644 --- a/app/models/concerns/reactive_caching.rb +++ b/app/models/concerns/reactive_caching.rb @@ -1,78 +1,7 @@ # frozen_string_literal: true -# The ReactiveCaching concern is used to fetch some data in the background and -# store it in the Rails cache, keeping it up-to-date for as long as it is being -# requested. If the data hasn't been requested for +reactive_cache_lifetime+, -# it stop being refreshed, and then be removed. -# -# Example of use: -# -# class Foo < ApplicationRecord -# include ReactiveCaching -# -# after_save :clear_reactive_cache! -# -# def calculate_reactive_cache -# # Expensive operation here. The return value of this method is cached -# end -# -# def result -# with_reactive_cache do |data| -# # ... -# end -# end -# end -# -# In this example, the first time `#result` is called, it will return `nil`. -# However, it will enqueue a background worker to call `#calculate_reactive_cache` -# and set an initial cache lifetime of ten minutes. -# -# The background worker needs to find or generate the object on which -# `with_reactive_cache` was called. -# The default behaviour can be overridden by defining a custom -# `reactive_cache_worker_finder`. -# Otherwise the background worker will use the class name and primary key to get -# the object using the ActiveRecord find_by method. -# -# class Bar -# include ReactiveCaching -# -# self.reactive_cache_key = ->() { ["bar", "thing"] } -# self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) } -# -# def self.from_cache(var1, var2) -# # This method will be called by the background worker with "bar1" and -# # "bar2" as arguments. -# new(var1, var2) -# end -# -# def initialize(var1, var2) -# # ... -# end -# -# def calculate_reactive_cache -# # Expensive operation here. The return value of this method is cached -# end -# -# def result -# with_reactive_cache("bar1", "bar2") do |data| -# # ... -# end -# end -# end -# -# Each time the background job completes, it stores the return value of -# `#calculate_reactive_cache`. It is also re-enqueued to run again after -# `reactive_cache_refresh_interval`, so keeping the stored value up to date. -# Calculations are never run concurrently. -# -# Calling `#result` while a value is in the cache will call the block given to -# `#with_reactive_cache`, yielding the cached value. It will also extend the -# lifetime by `reactive_cache_lifetime`. -# -# Once the lifetime has expired, no more background jobs will be enqueued and -# calling `#result` will again return `nil` - starting the process all over -# again +# The usage of the ReactiveCaching module is documented here: +# https://docs.gitlab.com/ee/development/utilities.html#reactivecaching module ReactiveCaching extend ActiveSupport::Concern @@ -122,6 +51,14 @@ module ReactiveCaching end end + # This method is used for debugging purposes and should not be used otherwise. + def without_reactive_cache(*args, &blk) + return with_reactive_cache(*args, &blk) unless Rails.env.development? + + data = self.class.reactive_cache_worker_finder.call(id, *args).calculate_reactive_cache(*args) + yield data + end + def clear_reactive_cache!(*args) Rails.cache.delete(full_reactive_cache_key(*args)) Rails.cache.delete(alive_reactive_cache_key(*args)) diff --git a/app/models/concerns/referable.rb b/app/models/concerns/referable.rb index 4a506146de3..3b0606aa425 100644 --- a/app/models/concerns/referable.rb +++ b/app/models/concerns/referable.rb @@ -74,7 +74,7 @@ module Referable #{Regexp.escape(Gitlab.config.gitlab.url)} \/#{Project.reference_pattern} (?:\/\-)? - \/#{Regexp.escape(route)} + \/#{route.is_a?(Regexp) ? route : Regexp.escape(route)} \/#{pattern} (?<path> (\/[a-z0-9_=-]+)* diff --git a/app/models/concerns/schedulable.rb b/app/models/concerns/schedulable.rb new file mode 100644 index 00000000000..6fdca4f50c3 --- /dev/null +++ b/app/models/concerns/schedulable.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Schedulable + extend ActiveSupport::Concern + + included do + scope :runnable_schedules, -> { active.where("next_run_at < ?", Time.zone.now) } + + before_save :set_next_run_at + end + + def schedule_next_run! + save! # with set_next_run_at + rescue ActiveRecord::RecordInvalid + update_column(:next_run_at, nil) # update without validation + end + + def set_next_run_at + raise NotImplementedError + end +end diff --git a/app/models/concerns/sha256_attribute.rb b/app/models/concerns/sha256_attribute.rb index 1bd1ad177a2..9dfe1b77829 100644 --- a/app/models/concerns/sha256_attribute.rb +++ b/app/models/concerns/sha256_attribute.rb @@ -39,11 +39,7 @@ module Sha256Attribute end def database_exists? - ApplicationRecord.connection - - true - rescue - false + Gitlab::Database.exists? end end end diff --git a/app/models/concerns/sha_attribute.rb b/app/models/concerns/sha_attribute.rb index c5826f58966..c807dcbf418 100644 --- a/app/models/concerns/sha_attribute.rb +++ b/app/models/concerns/sha_attribute.rb @@ -39,11 +39,7 @@ module ShaAttribute end def database_exists? - ApplicationRecord.connection - - true - rescue - false + Gitlab::Database.exists? end end end diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb index 98842242eb6..5debfa6f834 100644 --- a/app/models/concerns/taskable.rb +++ b/app/models/concerns/taskable.rb @@ -15,11 +15,11 @@ module Taskable INCOMPLETE_PATTERN = /(\[[\s]\])/.freeze ITEM_PATTERN = %r{ ^ - (?:(?:>\s{0,4})*) # optional blockquote characters - \s*(?:[-+*]|(?:\d+\.)) # list prefix required - task item has to be always in a list - \s+ # whitespace prefix has to be always presented for a list item - (\[\s\]|\[[xX]\]) # checkbox - (\s.+) # followed by whitespace and some text. + (?:(?:>\s{0,4})*) # optional blockquote characters + (?:\s*(?:[-+*]|(?:\d+\.)))+ # list prefix (one or more) required - task item has to be always in a list + \s+ # whitespace prefix has to be always presented for a list item + (\[\s\]|\[[xX]\]) # checkbox + (\s.+) # followed by whitespace and some text. }x.freeze def self.get_tasks(content) diff --git a/app/models/container_expiration_policy.rb b/app/models/container_expiration_policy.rb index f60a0179c83..c929a78a7f9 100644 --- a/app/models/container_expiration_policy.rb +++ b/app/models/container_expiration_policy.rb @@ -1,14 +1,21 @@ # frozen_string_literal: true class ContainerExpirationPolicy < ApplicationRecord + include Schedulable + belongs_to :project, inverse_of: :container_expiration_policy + delegate :container_repositories, to: :project + validates :project, presence: true validates :enabled, inclusion: { in: [true, false] } validates :cadence, presence: true, inclusion: { in: ->(_) { self.cadence_options.stringify_keys } } validates :older_than, inclusion: { in: ->(_) { self.older_than_options.stringify_keys } }, allow_nil: true validates :keep_n, inclusion: { in: ->(_) { self.keep_n_options.keys } }, allow_nil: true + scope :active, -> { where(enabled: true) } + scope :preloaded, -> { preload(:project) } + def self.keep_n_options { 1 => _('%{tags} tag per image name') % { tags: 1 }, @@ -38,4 +45,8 @@ class ContainerExpirationPolicy < ApplicationRecord '90d': _('%{days} days until tags are automatically removed') % { days: 90 } } end + + def set_next_run_at + self.next_run_at = Time.zone.now + ChronicDuration.parse(cadence).seconds + end end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 994e69912b6..e0daf692665 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -5,6 +5,7 @@ class Deployment < ApplicationRecord include IidRoutes include AfterCommitQueue include UpdatedAtFilterable + include Importable include Gitlab::Utils::StrongMemoize belongs_to :project, required: true @@ -17,16 +18,23 @@ class Deployment < ApplicationRecord has_many :merge_requests, through: :deployment_merge_requests - has_internal_id :iid, scope: :project, init: ->(s) do + has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) do Deployment.where(project: s.project).maximum(:iid) if s&.project end validates :sha, presence: true validates :ref, presence: true + validate :valid_sha, on: :create + validate :valid_ref, on: :create delegate :name, to: :environment, prefix: true scope :for_environment, -> (environment) { where(environment_id: environment) } + scope :for_environment_name, -> (name) do + joins(:environment).where(environments: { name: name }) + end + + scope :for_status, -> (status) { where(status: status) } scope :visible, -> { where(status: %i[running success failed canceled]) } @@ -210,10 +218,14 @@ class Deployment < ApplicationRecord # We don't use `Gitlab::Database.bulk_insert` here so that we don't need to # first pluck lots of IDs into memory. + # + # We also ignore any duplicates so this method can be called multiple times + # for the same deployment, only inserting any missing merge requests. DeploymentMergeRequest.connection.execute(<<~SQL) INSERT INTO #{DeploymentMergeRequest.table_name} (merge_request_id, deployment_id) #{select} + ON CONFLICT DO NOTHING SQL end @@ -234,6 +246,18 @@ class Deployment < ApplicationRecord end end + def valid_sha + return if project&.commit(sha) + + errors.add(:sha, _('The commit does not exist')) + end + + def valid_ref + return if project&.commit(ref) + + errors.add(:ref, _('The branch or tag does not exist')) + end + private def ref_path diff --git a/app/models/deployment_metrics.rb b/app/models/deployment_metrics.rb index 2056c8bc59c..c5f8b03f25b 100644 --- a/app/models/deployment_metrics.rb +++ b/app/models/deployment_metrics.rb @@ -13,18 +13,18 @@ class DeploymentMetrics end def has_metrics? - deployment.success? && prometheus_adapter&.can_query? + deployment.success? && prometheus_adapter&.configured? end def metrics - return {} unless has_metrics? + return {} unless has_metrics_and_can_query? metrics = prometheus_adapter.query(:deployment, deployment) metrics&.merge(deployment_time: deployment.finished_at.to_i) || {} end def additional_metrics - return {} unless has_metrics? + return {} unless has_metrics_and_can_query? metrics = prometheus_adapter.query(:additional_metrics_deployment, deployment) metrics&.merge(deployment_time: deployment.finished_at.to_i) || {} @@ -34,17 +34,11 @@ class DeploymentMetrics def prometheus_adapter strong_memoize(:prometheus_adapter) do - service = project.find_or_initialize_service('prometheus') - - if service.can_query? - service - else - cluster_prometheus - end + Gitlab::Prometheus::Adapter.new(project, cluster).prometheus_adapter end end - def cluster_prometheus - cluster.application_prometheus if cluster&.application_prometheus_available? + def has_metrics_and_can_query? + has_metrics? && prometheus_adapter.can_query? end end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index 686d06d3ee0..939d8bc4bef 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -23,6 +23,12 @@ class DiffNote < Note before_validation :set_line_code, if: :on_text?, unless: :importing? after_save :keep_around_commits, unless: :importing? + + NoteDiffFileCreationError = Class.new(StandardError) + + DIFF_LINE_NOT_FOUND_MESSAGE = "Failed to find diff line for: %{file_path}, old_line: %{old_line}, new_line: %{new_line}" + DIFF_FILE_NOT_FOUND_MESSAGE = "Failed to find diff file" + after_commit :create_diff_file, on: :create def discussion_class(*) @@ -33,7 +39,16 @@ class DiffNote < Note return unless should_create_diff_file? diff_file = fetch_diff_file + raise NoteDiffFileCreationError, DIFF_FILE_NOT_FOUND_MESSAGE unless diff_file + diff_line = diff_file.line_for_position(self.original_position) + unless diff_line + raise NoteDiffFileCreationError, DIFF_LINE_NOT_FOUND_MESSAGE % { + file_path: diff_file.file_path, + old_line: original_position.old_line, + new_line: original_position.new_line + } + end creation_params = diff_file.diff.to_hash .except(:too_large) @@ -110,19 +125,20 @@ class DiffNote < Note def fetch_diff_file return note_diff_file.raw_diff_file if note_diff_file - file = - if created_at_diff?(noteable.diff_refs) - # We're able to use the already persisted diffs (Postgres) if we're - # presenting a "current version" of the MR discussion diff. - # So no need to make an extra Gitaly diff request for it. - # As an extra benefit, the returned `diff_file` already - # has `highlighted_diff_lines` data set from Redis on - # `Diff::FileCollection::MergeRequestDiff`. - noteable.diffs(original_position.diff_options).diff_files.first - else - original_position.diff_file(repository) - end + if created_at_diff?(noteable.diff_refs) + # We're able to use the already persisted diffs (Postgres) if we're + # presenting a "current version" of the MR discussion diff. + # So no need to make an extra Gitaly diff request for it. + # As an extra benefit, the returned `diff_file` already + # has `highlighted_diff_lines` data set from Redis on + # `Diff::FileCollection::MergeRequestDiff`. + file = noteable.diffs(original_position.diff_options).diff_files.first + # if line is not found in persisted diffs, fallback and retrieve file from repository using gitaly + # This is required because of https://gitlab.com/gitlab-org/gitlab/issues/42676 + file = nil if file&.line_for_position(original_position).nil? && importing? + end + file ||= original_position.diff_file(repository) file&.unfold_diff_lines(position) file diff --git a/app/models/diff_viewer/base.rb b/app/models/diff_viewer/base.rb index 22c8fe73563..75aa51348c8 100644 --- a/app/models/diff_viewer/base.rb +++ b/app/models/diff_viewer/base.rb @@ -4,7 +4,7 @@ module DiffViewer class Base PARTIAL_PATH_PREFIX = 'projects/diffs/viewers' - class_attribute :partial_name, :type, :extensions, :file_types, :binary, :switcher_icon, :switcher_title + class_attribute :partial_name, :type, :extensions, :binary, :switcher_icon, :switcher_title # These limits relate to the sum of the old and new blob sizes. # Limits related to the actual size of the diff are enforced in Gitlab::Diff::File. @@ -50,7 +50,6 @@ module DiffViewer return true if blob.nil? return false if verify_binary && binary? != blob.binary_in_repo? return true if extensions&.include?(blob.extension) - return true if file_types&.include?(blob.file_type) false end @@ -89,7 +88,7 @@ module DiffViewer { viewer: switcher_title, reason: render_error_reason, - options: render_error_options.to_sentence(two_words_connector: _(' or '), last_word_connector: _(', or ')) + options: Gitlab::Utils.to_exclusive_sentence(render_error_options) } end diff --git a/app/models/diff_viewer/collapsed.rb b/app/models/diff_viewer/collapsed.rb new file mode 100644 index 00000000000..b533bd8b88d --- /dev/null +++ b/app/models/diff_viewer/collapsed.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module DiffViewer + class Collapsed < Base + include Simple + include Static + + self.partial_name = 'collapsed' + end +end diff --git a/app/models/environment.rb b/app/models/environment.rb index b928dcb21a6..2d480345b5a 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -48,13 +48,14 @@ class Environment < ApplicationRecord scope :available, -> { with_state(:available) } scope :stopped, -> { with_state(:stopped) } + scope :order_by_last_deployed_at, -> do - max_deployment_id_sql = - Deployment.select(Deployment.arel_table[:id].maximum) - .where(Deployment.arel_table[:environment_id].eq(arel_table[:id])) - .to_sql order(Gitlab::Database.nulls_first_order("(#{max_deployment_id_sql})", 'ASC')) end + scope :order_by_last_deployed_at_desc, -> do + order(Gitlab::Database.nulls_last_order("(#{max_deployment_id_sql})", 'DESC')) + end + scope :in_review_folder, -> { where(environment_type: "review") } scope :for_name, -> (name) { where(name: name) } scope :preload_cluster, -> { preload(last_deployment: :cluster) } @@ -90,6 +91,12 @@ class Environment < ApplicationRecord end end + def self.max_deployment_id_sql + Deployment.select(Deployment.arel_table[:id].maximum) + .where(Deployment.arel_table[:environment_id].eq(arel_table[:id])) + .to_sql + end + def self.pluck_names pluck(:name) end @@ -197,11 +204,15 @@ class Environment < ApplicationRecord end def has_metrics? - available? && prometheus_adapter&.configured? + available? && (prometheus_adapter&.configured? || has_sample_metrics?) + end + + def has_sample_metrics? + !!ENV['USE_SAMPLE_METRICS'] end def metrics - prometheus_adapter.query(:environment, self) if has_metrics? && prometheus_adapter.can_query? + prometheus_adapter.query(:environment, self) if has_metrics_and_can_query? end def prometheus_status @@ -209,16 +220,14 @@ class Environment < ApplicationRecord end def additional_metrics(*args) - return unless has_metrics? + return unless has_metrics_and_can_query? prometheus_adapter.query(:additional_metrics_environment, self, *args.map(&:to_f)) end - # rubocop: disable CodeReuse/ServiceClass def prometheus_adapter - @prometheus_adapter ||= Prometheus::AdapterService.new(project, deployment_platform).prometheus_adapter + @prometheus_adapter ||= Gitlab::Prometheus::Adapter.new(project, deployment_platform&.cluster).prometheus_adapter end - # rubocop: enable CodeReuse/ServiceClass def slug super.presence || generate_slug @@ -278,6 +287,10 @@ class Environment < ApplicationRecord private + def has_metrics_and_can_query? + has_metrics? && prometheus_adapter.can_query? + end + def generate_slug self.slug = Gitlab::Slug::Environment.new(name).generate end diff --git a/app/models/epic.rb b/app/models/epic.rb index 8222bbf9656..1203c6c1fc3 100644 --- a/app/models/epic.rb +++ b/app/models/epic.rb @@ -5,7 +5,7 @@ class Epic < ApplicationRecord include IgnorableColumns - ignore_column :milestone_id, remove_after: '2019-12-15', remove_with: '12.7' + ignore_column :milestone_id, remove_after: '2020-02-01', remove_with: '12.8' def self.link_reference_pattern nil diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb index 6a9986e806b..a904cf4ac46 100644 --- a/app/models/error_tracking/project_error_tracking_setting.rb +++ b/app/models/error_tracking/project_error_tracking_setting.rb @@ -4,6 +4,7 @@ module ErrorTracking class ProjectErrorTrackingSetting < ApplicationRecord include Gitlab::Utils::StrongMemoize include ReactiveCaching + include Gitlab::Routing SENTRY_API_ERROR_TYPE_BAD_REQUEST = 'bad_request_for_sentry_api' SENTRY_API_ERROR_TYPE_MISSING_KEYS = 'missing_keys_in_sentry_response' @@ -101,34 +102,33 @@ module ErrorTracking end end + def update_issue(opts = {} ) + handle_exceptions do + { updated: sentry_client.update_issue(opts) } + end + end + def calculate_reactive_cache(request, opts) - case request - when 'list_issues' - sentry_client.list_issues(**opts.symbolize_keys) - when 'issue_details' - { - issue: sentry_client.issue_details(**opts.symbolize_keys) - } - when 'issue_latest_event' - { - latest_event: sentry_client.issue_latest_event(**opts.symbolize_keys) - } + handle_exceptions do + case request + when 'list_issues' + sentry_client.list_issues(**opts.symbolize_keys) + when 'issue_details' + issue = sentry_client.issue_details(**opts.symbolize_keys) + { issue: add_gitlab_issue_details(issue) } + when 'issue_latest_event' + { + latest_event: sentry_client.issue_latest_event(**opts.symbolize_keys) + } + end end - rescue Sentry::Client::Error => e - { error: e.message, error_type: SENTRY_API_ERROR_TYPE_NON_20X_RESPONSE } - rescue Sentry::Client::MissingKeysError => e - { error: e.message, error_type: SENTRY_API_ERROR_TYPE_MISSING_KEYS } - rescue Sentry::Client::ResponseInvalidSizeError => e - { error: e.message, error_type: SENTRY_API_ERROR_INVALID_SIZE } - rescue Sentry::Client::BadRequestError => e - { error: e.message, error_type: SENTRY_API_ERROR_TYPE_BAD_REQUEST } end # http://HOST/api/0/projects/ORG/PROJECT # -> # http://HOST/ORG/PROJECT def self.extract_sentry_external_url(url) - url.sub('api/0/projects/', '') + url&.sub('api/0/projects/', '') end def api_host @@ -140,6 +140,36 @@ module ErrorTracking private + def add_gitlab_issue_details(issue) + issue.gitlab_commit = match_gitlab_commit(issue.first_release_version) + issue.gitlab_commit_path = project_commit_path(project, issue.gitlab_commit) if issue.gitlab_commit + + issue + end + + def match_gitlab_commit(release_version) + return unless release_version + + commit = project.repository.commit(release_version) + + commit&.id + end + + def handle_exceptions + yield + rescue Sentry::Client::Error => e + { error: e.message, error_type: SENTRY_API_ERROR_TYPE_NON_20X_RESPONSE } + rescue Sentry::Client::MissingKeysError => e + { error: e.message, error_type: SENTRY_API_ERROR_TYPE_MISSING_KEYS } + rescue Sentry::Client::ResponseInvalidSizeError => e + { error: e.message, error_type: SENTRY_API_ERROR_INVALID_SIZE } + rescue Sentry::Client::BadRequestError => e + { error: e.message, error_type: SENTRY_API_ERROR_TYPE_BAD_REQUEST } + rescue StandardError => e + Gitlab::ErrorTracking.track_exception(e) + { error: 'Unexpected Error' } + end + def project_name_from_slug @project_name_from_slug ||= project_slug_from_api_url&.titleize end diff --git a/app/models/event_collection.rb b/app/models/event_collection.rb index 4778f74568e..4768506b8fa 100644 --- a/app/models/event_collection.rb +++ b/app/models/event_collection.rb @@ -30,17 +30,24 @@ class EventCollection relation = if groups project_and_group_events else - relation_with_join_lateral('project_id', projects) + project_events end relation = paginate_events(relation) relation.with_associations.to_a end + def all_project_events + Event.from_union([project_events]).recent + end + private + def project_events + relation_with_join_lateral('project_id', projects) + end + def project_and_group_events - project_events = relation_with_join_lateral('project_id', projects) group_events = relation_with_join_lateral('group_id', groups) Event.from_union([project_events, group_events]).recent diff --git a/app/models/group.rb b/app/models/group.rb index 8289d4f099c..b642b177df1 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -57,6 +57,8 @@ class Group < Namespace has_one :import_export_upload + has_many :import_failures, inverse_of: :group + accepts_nested_attributes_for :variables, allow_destroy: true validate :visibility_level_allowed_by_projects @@ -420,6 +422,12 @@ class Group < Namespace GroupMember.where(source_id: self_and_ancestors_ids, user_id: user.id).order(:access_level).last end + def related_group_ids + [id, + *ancestors.pluck(:id), + *shared_with_group_links.pluck(:shared_with_group_id)] + end + def hashed_storage?(_feature) false end @@ -490,7 +498,7 @@ class Group < Namespace end def max_member_access_for_user_from_shared_groups(user) - return unless Feature.enabled?(:share_group_with_group) + return unless Feature.enabled?(:share_group_with_group, default_enabled: true) group_group_link_table = GroupGroupLink.arel_table group_member_table = GroupMember.arel_table diff --git a/app/models/group_group_link.rb b/app/models/group_group_link.rb index 4b279b7af5b..5a0d9b08cb0 100644 --- a/app/models/group_group_link.rb +++ b/app/models/group_group_link.rb @@ -20,4 +20,8 @@ class GroupGroupLink < ApplicationRecord def self.default_access Gitlab::Access::DEVELOPER end + + def human_access + Gitlab::Access.human_access(self.group_access) + end end diff --git a/app/models/import_failure.rb b/app/models/import_failure.rb index 998572853d3..a1e03218640 100644 --- a/app/models/import_failure.rb +++ b/app/models/import_failure.rb @@ -2,6 +2,8 @@ class ImportFailure < ApplicationRecord belongs_to :project + belongs_to :group - validates :project, presence: true + validates :project, presence: true, unless: :group + validates :group, presence: true, unless: :project end diff --git a/app/models/issue.rb b/app/models/issue.rb index 88df3baa809..bf600278162 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -31,7 +31,10 @@ class Issue < ApplicationRecord belongs_to :duplicated_to, class_name: 'Issue' belongs_to :closed_by, class_name: 'User' - has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.issues&.maximum(:iid) } + has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) { s&.project&.issues&.maximum(:iid) } + + has_many :issue_milestones + has_many :milestones, through: :issue_milestones has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent @@ -75,8 +78,8 @@ class Issue < ApplicationRecord ignore_column :state, remove_with: '12.7', remove_after: '2019-12-22' - after_commit :expire_etag_cache - after_save :ensure_metrics, unless: :imported? + after_commit :expire_etag_cache, unless: :importing? + after_save :ensure_metrics, unless: :importing? attr_spammable :title, spam_title: true attr_spammable :description, spam_description: true diff --git a/app/models/issue_milestone.rb b/app/models/issue_milestone.rb new file mode 100644 index 00000000000..da030077d87 --- /dev/null +++ b/app/models/issue_milestone.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class IssueMilestone < ApplicationRecord + belongs_to :milestone + belongs_to :issue +end diff --git a/app/models/key.rb b/app/models/key.rb index e549c59b58f..71188f210bb 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -142,13 +142,9 @@ class Key < ApplicationRecord end def forbidden_key_type_message - allowed_types = - Gitlab::CurrentSettings - .allowed_key_types - .map(&:upcase) - .to_sentence(last_word_connector: ', or ', two_words_connector: ' or ') + allowed_types = Gitlab::CurrentSettings.allowed_key_types.map(&:upcase) - "type is forbidden. Must be #{allowed_types}" + "type is forbidden. Must be #{Gitlab::Utils.to_exclusive_sentence(allowed_types)}" end end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 2280c5280d5..7162ba08a76 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -31,10 +31,13 @@ class MergeRequest < ApplicationRecord belongs_to :source_project, class_name: "Project" belongs_to :merge_user, class_name: "User" - has_internal_id :iid, scope: :target_project, init: ->(s) { s&.target_project&.merge_requests&.maximum(:iid) } + has_internal_id :iid, scope: :target_project, track_if: -> { !importing? }, init: ->(s) { s&.target_project&.merge_requests&.maximum(:iid) } has_many :merge_request_diffs + has_many :merge_request_milestones + has_many :milestones, through: :merge_request_milestones + has_one :merge_request_diff, -> { order('merge_request_diffs.id DESC') }, inverse_of: :merge_request @@ -94,8 +97,8 @@ class MergeRequest < ApplicationRecord after_create :ensure_merge_request_diff after_update :clear_memoized_shas after_update :reload_diff_if_branch_changed - after_save :ensure_metrics - after_commit :expire_etag_cache + after_save :ensure_metrics, unless: :importing? + after_commit :expire_etag_cache, unless: :importing? # When this attribute is true some MR validation is ignored # It allows us to close or modify broken merge requests @@ -255,12 +258,10 @@ class MergeRequest < ApplicationRecord alias_method :issuing_parent, :target_project delegate :active?, to: :head_pipeline, prefix: true, allow_nil: true - delegate :success?, to: :actual_head_pipeline, prefix: true, allow_nil: true + delegate :success?, :active?, to: :actual_head_pipeline, prefix: true, allow_nil: true RebaseLockTimeout = Class.new(StandardError) - REBASE_LOCK_MESSAGE = _("Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later.") - def self.reference_prefix '!' end @@ -448,7 +449,7 @@ class MergeRequest < ApplicationRecord # Set off a rebase asynchronously, atomically updating the `rebase_jid` of # the MR so that the status of the operation can be tracked. - def rebase_async(user_id) + def rebase_async(user_id, skip_ci: false) with_rebase_lock do raise ActiveRecord::StaleObjectError if !open? || rebase_in_progress? @@ -457,7 +458,7 @@ class MergeRequest < ApplicationRecord # attribute is set *and* that the sidekiq job is still running. So a JID # for a completed RebaseWorker is equivalent to a nil JID. jid = Sidekiq::Worker.skipping_transaction_check do - RebaseWorker.perform_async(id, user_id) + RebaseWorker.perform_async(id, user_id, skip_ci) end update_column(:rebase_jid, jid) @@ -1122,22 +1123,18 @@ class MergeRequest < ApplicationRecord actual_head_pipeline.success? end - def environments_for(current_user) + def environments_for(current_user, latest: false) return [] unless diff_head_commit - @environments ||= Hash.new do |h, current_user| - envs = EnvironmentsFinder.new(target_project, current_user, - ref: target_branch, commit: diff_head_commit, with_tags: true).execute + envs = EnvironmentsFinder.new(target_project, current_user, + ref: target_branch, commit: diff_head_commit, with_tags: true, find_latest: latest).execute - if source_project - envs.concat EnvironmentsFinder.new(source_project, current_user, - ref: source_branch, commit: diff_head_commit).execute - end - - h[current_user] = envs.uniq + if source_project + envs.concat EnvironmentsFinder.new(source_project, current_user, + ref: source_branch, commit: diff_head_commit, find_latest: latest).execute end - @environments[current_user] + envs.uniq end ## @@ -1515,7 +1512,7 @@ class MergeRequest < ApplicationRecord end rescue ActiveRecord::LockWaitTimeout => e Gitlab::ErrorTracking.track_exception(e) - raise RebaseLockTimeout, REBASE_LOCK_MESSAGE + raise RebaseLockTimeout, _('Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later.') end def source_project_variables diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 71a344e69e3..fa633a1a725 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -138,7 +138,7 @@ class MergeRequestDiff < ApplicationRecord # All diff information is collected from repository after object is created. # It allows you to override variables like head_commit_sha before getting diff. after_create :save_git_content, unless: :importing? - after_create_commit :set_as_latest_diff + after_create_commit :set_as_latest_diff, unless: :importing? after_save :update_external_diff_store, if: -> { !importing? && saved_change_to_external_diff? } diff --git a/app/models/merge_request_milestone.rb b/app/models/merge_request_milestone.rb new file mode 100644 index 00000000000..4fa1d1dcb33 --- /dev/null +++ b/app/models/merge_request_milestone.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class MergeRequestMilestone < ApplicationRecord + belongs_to :milestone + belongs_to :merge_request +end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 987373aaf1b..5da92fc4bc5 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -17,6 +17,7 @@ class Milestone < ApplicationRecord include StripAttribute include Milestoneish include FromUnion + include Importable include Gitlab::SQL::Pattern prepend_if_ee('::EE::Milestone') # rubocop: disable Cop/InjectEnterpriseEditionModule @@ -30,14 +31,17 @@ class Milestone < ApplicationRecord has_many :milestone_releases has_many :releases, through: :milestone_releases - has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.milestones&.maximum(:iid) } - has_internal_id :iid, scope: :group, init: ->(s) { s&.group&.milestones&.maximum(:iid) } + has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) { s&.project&.milestones&.maximum(:iid) } + has_internal_id :iid, scope: :group, track_if: -> { !importing? }, init: ->(s) { s&.group&.milestones&.maximum(:iid) } has_many :issues has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues has_many :merge_requests has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent + has_many :issue_milestones + has_many :merge_request_milestones + scope :of_projects, ->(ids) { where(project_id: ids) } scope :of_groups, ->(ids) { where(group_id: ids) } scope :active, -> { with_state(:active) } diff --git a/app/models/namespace.rb b/app/models/namespace.rb index d5a7c172fec..621a98e9ab6 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -46,6 +46,8 @@ class Namespace < ApplicationRecord length: { maximum: 255 }, namespace_path: true + validates :max_artifacts_size, numericality: { only_integer: true, greater_than: 0, allow_nil: true } + validate :nesting_level_allowed validates_associated :runners @@ -184,7 +186,11 @@ class Namespace < ApplicationRecord # any ancestor can disable emails for all descendants def emails_disabled? strong_memoize(:emails_disabled) do - self_and_ancestors.where(emails_disabled: true).exists? + if parent_id + self_and_ancestors.where(emails_disabled: true).exists? + else + !!emails_disabled + end end end diff --git a/app/models/note.rb b/app/models/note.rb index cfa7ba98081..7731b477ad0 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -152,9 +152,7 @@ class Note < ApplicationRecord scope :for_note_or_capitalized_note, ->(text) { where(note: [text, text.capitalize]) } scope :like_note_or_capitalized_note, ->(text) { where('(note LIKE ? OR note LIKE ?)', text, text.capitalize) } - after_initialize :ensure_discussion_id before_validation :nullify_blank_type, :nullify_blank_line_code - before_validation :set_discussion_id, on: :create after_save :keep_around_commit, if: :for_project_noteable?, unless: :importing? after_save :expire_etag_cache, unless: :importing? after_save :touch_noteable, unless: :importing? @@ -394,7 +392,7 @@ class Note < ApplicationRecord # See `Discussion.override_discussion_id` for details. def discussion_id(noteable = nil) - discussion_class(noteable).override_discussion_id(self) || super() + discussion_class(noteable).override_discussion_id(self) || super() || ensure_discussion_id end # Returns a discussion containing just this note. @@ -533,17 +531,13 @@ class Note < ApplicationRecord end def ensure_discussion_id - return unless self.persisted? - # Needed in case the SELECT statement doesn't ask for `discussion_id` - return unless self.has_attribute?(:discussion_id) - return if self.discussion_id + return if self.attribute_present?(:discussion_id) - set_discussion_id - update_column(:discussion_id, self.discussion_id) + self.discussion_id = derive_discussion_id end - def set_discussion_id - self.discussion_id ||= discussion_class.discussion_id(self) + def derive_discussion_id + discussion_class.discussion_id(self) end def all_referenced_mentionables_allowed?(user) diff --git a/app/models/project.rb b/app/models/project.rb index 3f6c2d6a448..c48360290c7 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -63,10 +63,6 @@ class Project < ApplicationRecord cache_markdown_field :description, pipeline: :description - # TODO: remove once GitLab 12.5 is released - # https://gitlab.com/gitlab-org/gitlab/issues/34638 - ignore_column :merge_requests_require_code_owner_approval, remove_after: '2019-12-01', remove_with: '12.6' - default_value_for :archived, false default_value_for :resolve_outdated_diff_discussions, false default_value_for :container_registry_enabled, gitlab_config_features.container_registry @@ -79,6 +75,7 @@ class Project < ApplicationRecord default_value_for :snippets_enabled, gitlab_config_features.snippets default_value_for :only_allow_merge_if_all_discussions_are_resolved, false default_value_for :remove_source_branch_after_merge, true + default_value_for :autoclose_referenced_issues, true default_value_for(:ci_config_path) { Gitlab::CurrentSettings.default_ci_config_path } add_authentication_token_field :runners_token, encrypted: -> { Feature.enabled?(:projects_tokens_optional_encryption, default_enabled: true) ? :optional : :required } @@ -285,6 +282,7 @@ class Project < ApplicationRecord has_many :pipeline_schedules, class_name: 'Ci::PipelineSchedule' has_many :project_deploy_tokens has_many :deploy_tokens, through: :project_deploy_tokens + has_many :resource_groups, class_name: 'Ci::ResourceGroup', inverse_of: :project has_one :auto_devops, class_name: 'ProjectAutoDevops', inverse_of: :project, autosave: true has_many :custom_attributes, class_name: 'ProjectCustomAttribute' @@ -319,10 +317,12 @@ class Project < ApplicationRecord accepts_nested_attributes_for :metrics_setting, update_only: true, allow_destroy: true accepts_nested_attributes_for :grafana_integration, update_only: true, allow_destroy: true - delegate :feature_available?, :builds_enabled?, :wiki_enabled?, :merge_requests_enabled?, - :issues_enabled?, :pages_enabled?, :public_pages?, :private_pages?, - :merge_requests_access_level, :issues_access_level, :wiki_access_level, - :snippets_access_level, :builds_access_level, :repository_access_level, + delegate :feature_available?, :builds_enabled?, :wiki_enabled?, + :merge_requests_enabled?, :forking_enabled?, :issues_enabled?, + :pages_enabled?, :public_pages?, :private_pages?, + :merge_requests_access_level, :forking_access_level, :issues_access_level, + :wiki_access_level, :snippets_access_level, :builds_access_level, + :repository_access_level, to: :project_feature, allow_nil: true delegate :scheduled?, :started?, :in_progress?, :failed?, :finished?, prefix: :import, to: :import_state, allow_nil: true @@ -334,7 +334,7 @@ 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 :root_ancestor, :actual_limits, to: :namespace, 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 delegate :default_git_depth, :default_git_depth=, to: :ci_cd_settings, prefix: :ci @@ -374,6 +374,7 @@ class Project < ApplicationRecord inclusion: { in: ->(_object) { Gitlab.config.repositories.storages.keys } } validates :variables, variable_duplicates: { scope: :environment_scope } validates :bfg_object_map, file_size: { maximum: :max_attachment_size } + validates :max_artifacts_size, numericality: { only_integer: true, greater_than: 0, allow_nil: true } # Scopes scope :pending_delete, -> { where(pending_delete: true) } @@ -681,6 +682,12 @@ class Project < ApplicationRecord end end + def autoclose_referenced_issues + return true if super.nil? + + super + end + def preload_protected_branches preloader = ActiveRecord::Associations::Preloader.new preloader.preload(self, protected_branches: [:push_access_levels, :merge_access_levels]) @@ -1320,7 +1327,7 @@ class Project < ApplicationRecord end def has_active_hooks?(hooks_scope = :push_hooks) - hooks.hooks_for(hooks_scope).any? || SystemHook.hooks_for(hooks_scope).any? || Gitlab::Plugin.any? + hooks.hooks_for(hooks_scope).any? || SystemHook.hooks_for(hooks_scope).any? || Gitlab::FileHook.any? end def has_active_services?(hooks_scope = :push_hooks) @@ -1509,7 +1516,7 @@ class Project < ApplicationRecord end def default_branch - @default_branch ||= repository.root_ref if repository.exists? + @default_branch ||= repository.root_ref end def reload_default_branch @@ -1927,6 +1934,7 @@ class Project < ApplicationRecord Gitlab::Ci::Variables::Collection.new .append(key: 'CI', value: 'true') .append(key: 'GITLAB_CI', value: 'true') + .append(key: 'CI_SERVER_URL', value: Gitlab.config.gitlab.url) .append(key: 'CI_SERVER_HOST', value: Gitlab.config.gitlab.host) .append(key: 'CI_SERVER_NAME', value: 'GitLab') .append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION) diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb index b292d39dae7..1dd65c76258 100644 --- a/app/models/project_ci_cd_setting.rb +++ b/app/models/project_ci_cd_setting.rb @@ -4,7 +4,6 @@ class ProjectCiCdSetting < ApplicationRecord include IgnorableColumns # https://gitlab.com/gitlab-org/gitlab/issues/36651 ignore_column :merge_trains_enabled, remove_with: '12.7', remove_after: '2019-12-22' - belongs_to :project, inverse_of: :ci_cd_settings # The version of the schema that first introduced this model/table. diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 4973c7761c1..a9753c3c53a 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -22,7 +22,7 @@ class ProjectFeature < ApplicationRecord ENABLED = 20 PUBLIC = 30 - FEATURES = %i(issues merge_requests wiki snippets builds repository pages).freeze + FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages).freeze PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { merge_requests: Gitlab::Access::REPORTER }.freeze PRIVATE_FEATURES_MIN_ACCESS_LEVEL_FOR_PRIVATE_PROJECT = { repository: Gitlab::Access::REPORTER }.freeze STRING_OPTIONS = HashWithIndifferentAccess.new({ @@ -92,12 +92,19 @@ class ProjectFeature < ApplicationRecord default_value_for :builds_access_level, value: ENABLED, allows_nil: false default_value_for :issues_access_level, value: ENABLED, allows_nil: false + default_value_for :forking_access_level, value: ENABLED, allows_nil: false default_value_for :merge_requests_access_level, value: ENABLED, allows_nil: false default_value_for :snippets_access_level, value: ENABLED, allows_nil: false default_value_for :wiki_access_level, value: ENABLED, allows_nil: false default_value_for :repository_access_level, value: ENABLED, allows_nil: false - default_value_for(:pages_access_level, allows_nil: false) { |feature| feature.project&.public? ? ENABLED : PRIVATE } + default_value_for(:pages_access_level, allows_nil: false) do |feature| + if ::Gitlab::Pages.access_control_is_forced? + PRIVATE + else + feature.project&.public? ? ENABLED : PRIVATE + end + end def feature_available?(feature, user) # This feature might not be behind a feature flag at all, so default to true @@ -126,6 +133,10 @@ class ProjectFeature < ApplicationRecord merge_requests_access_level > DISABLED end + def forking_enabled? + forking_access_level > DISABLED + end + def issues_enabled? issues_access_level > DISABLED end @@ -137,6 +148,8 @@ class ProjectFeature < ApplicationRecord def public_pages? return true unless Gitlab.config.pages.access_control + return false if ::Gitlab::Pages.access_control_is_forced? + pages_access_level == PUBLIC || pages_access_level == ENABLED && project.public? end diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb index 0d3a2d4e398..b70c07a8386 100644 --- a/app/models/project_group_link.rb +++ b/app/models/project_group_link.rb @@ -21,6 +21,8 @@ class ProjectGroupLink < ApplicationRecord after_commit :refresh_group_members_authorized_projects + alias_method :shared_with_group, :group + def self.access_options Gitlab::Access.options end diff --git a/app/models/project_services/chat_message/base_message.rb b/app/models/project_services/chat_message/base_message.rb index 6542112ba32..529af1277b0 100644 --- a/app/models/project_services/chat_message/base_message.rb +++ b/app/models/project_services/chat_message/base_message.rb @@ -4,6 +4,8 @@ require 'slack-notifier' module ChatMessage class BaseMessage + RELATIVE_LINK_REGEX = /!\[[^\]]*\]\((\/uploads\/[^\)]*)\)/.freeze + attr_reader :markdown attr_reader :user_full_name attr_reader :user_name @@ -59,7 +61,11 @@ module ChatMessage end def format(string) - Slack::Notifier::LinkFormatter.format(string) + Slack::Notifier::LinkFormatter.format(format_relative_links(string)) + end + + def format_relative_links(string) + string.gsub(RELATIVE_LINK_REGEX, "#{project_url}\\1") end def attachment_color diff --git a/app/models/project_services/chat_message/wiki_page_message.rb b/app/models/project_services/chat_message/wiki_page_message.rb index b605d289278..ebe7abb379f 100644 --- a/app/models/project_services/chat_message/wiki_page_message.rb +++ b/app/models/project_services/chat_message/wiki_page_message.rb @@ -14,7 +14,7 @@ module ChatMessage obj_attr = HashWithIndifferentAccess.new(obj_attr) @title = obj_attr[:title] @wiki_page_url = obj_attr[:url] - @description = obj_attr[:content] + @description = obj_attr[:message] @action = case obj_attr[:action] diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb index 8ca40138a8f..eb78938324d 100644 --- a/app/models/project_services/emails_on_push_service.rb +++ b/app/models/project_services/emails_on_push_service.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true class EmailsOnPushService < Service + include NotificationBranchSelection + boolean_accessor :send_from_committer_email boolean_accessor :disable_diffs - prop_accessor :recipients + prop_accessor :recipients, :branches_to_be_notified validates :recipients, presence: true, if: :valid_recipients? def title @@ -22,9 +24,17 @@ class EmailsOnPushService < Service %w(push tag_push) end + def initialize_properties + if properties.nil? + self.properties = {} + self.branches_to_be_notified ||= "all" + end + end + def execute(push_data) return unless supported_events.include?(push_data[:object_kind]) return if project.emails_disabled? + return unless notify_for_ref?(push_data) EmailsOnPushWorker.perform_async( project_id, @@ -35,6 +45,13 @@ class EmailsOnPushService < Service ) end + def notify_for_ref?(push_data) + return true if push_data[:object_kind] == 'tag_push' + return true if push_data.dig(:object_attributes, :tag) + + notify_for_branch?(push_data) + end + def send_from_committer_email? Gitlab::Utils.to_boolean(self.send_from_committer_email) end @@ -50,6 +67,7 @@ class EmailsOnPushService < Service help: s_("EmailsOnPushService|Send notifications from the committer's email address if the domain is part of the domain GitLab is running on (e.g. %{domains}).") % { domains: domains } }, { type: 'checkbox', name: 'disable_diffs', title: s_("EmailsOnPushService|Disable code diffs"), help: s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") }, + { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES }, { type: 'textarea', name: 'recipients', placeholder: s_('EmailsOnPushService|Emails separated by whitespace') } ] end diff --git a/app/models/project_services/external_wiki_service.rb b/app/models/project_services/external_wiki_service.rb index 593ce69b0fd..0a09000fff4 100644 --- a/app/models/project_services/external_wiki_service.rb +++ b/app/models/project_services/external_wiki_service.rb @@ -19,15 +19,20 @@ class ExternalWikiService < Service def fields [ - { type: 'text', name: 'external_wiki_url', placeholder: s_('ExternalWikiService|The URL of the external Wiki'), required: true } + { + type: 'text', + name: 'external_wiki_url', + placeholder: s_('ExternalWikiService|The URL of the external Wiki'), + required: true + } ] end def execute(_data) - @response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true) rescue nil - if @response != 200 - nil - end + response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true) + response.body if response.code == 200 + rescue + nil end def self.supported_events diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index 48c96203921..f4666197def 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -7,7 +7,8 @@ class ProjectWiki MARKUPS = { 'Markdown' => :markdown, 'RDoc' => :rdoc, - 'AsciiDoc' => :asciidoc + 'AsciiDoc' => :asciidoc, + 'Org' => :org }.freeze unless defined?(MARKUPS) CouldNotCreateWikiError = Class.new(StandardError) diff --git a/app/models/release.rb b/app/models/release.rb index 4fac64689ab..ecfae554fe0 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -3,6 +3,7 @@ class Release < ApplicationRecord include Presentable include CacheMarkdownField + include Importable include Gitlab::Utils::StrongMemoize cache_markdown_field :description @@ -33,8 +34,8 @@ class Release < ApplicationRecord delegate :repository, to: :project - after_commit :create_evidence!, on: :create - after_commit :notify_new_release, on: :create + after_commit :create_evidence!, on: :create, unless: :importing? + after_commit :notify_new_release, on: :create, unless: :importing? MAX_NUMBER_TO_DISPLAY = 3 @@ -81,6 +82,10 @@ class Release < ApplicationRecord evidence&.summary || {} end + def milestone_titles + self.milestones.map {|m| m.title }.sort.join(", ") + end + private def actual_sha diff --git a/app/models/repository.rb b/app/models/repository.rb index 2a67c26d840..c53b2fc5340 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -95,7 +95,7 @@ class Repository def path_to_repo @path_to_repo ||= begin - storage = Gitlab.config.repositories.storages[@project.repository_storage] + storage = Gitlab.config.repositories.storages[project.repository_storage] File.expand_path( File.join(storage.legacy_disk_path, disk_path + '.git') @@ -128,7 +128,7 @@ class Repository commits = Gitlab::Git::Commit.batch_by_oid(raw_repository, oids) if commits.present? - Commit.decorate(commits, @project) + Commit.decorate(commits, project) else [] end @@ -159,14 +159,14 @@ class Repository } commits = Gitlab::Git::Commit.where(options) - commits = Commit.decorate(commits, @project) if commits.present? + commits = Commit.decorate(commits, project) if commits.present? CommitCollection.new(project, commits, ref) end def commits_between(from, to) commits = Gitlab::Git::Commit.between(raw_repository, from, to) - commits = Commit.decorate(commits, @project) if commits.present? + commits = Commit.decorate(commits, project) if commits.present? commits end @@ -695,13 +695,13 @@ class Repository commits = raw_repository.list_last_commits_for_tree(sha, path, offset: offset, limit: limit) commits.each do |path, commit| - commits[path] = ::Commit.new(commit, @project) + commits[path] = ::Commit.new(commit, project) end end def last_commit_for_path(sha, path) commit = raw_repository.last_commit_for_path(sha, path) - ::Commit.new(commit, @project) if commit + ::Commit.new(commit, project) if commit end def last_commit_id_for_path(sha, path) @@ -1062,18 +1062,22 @@ class Repository rebase_sha end - def rebase(user, merge_request) + def rebase(user, merge_request, skip_ci: false) if Feature.disabled?(:two_step_rebase, default_enabled: true) return rebase_deprecated(user, merge_request) end + push_options = [] + push_options << Gitlab::PushOptions::CI_SKIP if skip_ci + raw.rebase( user, merge_request.id, branch: merge_request.source_branch, branch_sha: merge_request.source_branch_sha, remote_repository: merge_request.target_project.repository.raw, - remote_branch: merge_request.target_branch + remote_branch: merge_request.target_branch, + push_options: push_options ) do |commit_id| merge_request.update!(rebase_commit_sha: commit_id, merge_error: nil) end @@ -1127,8 +1131,8 @@ class Repository private - # TODO Generice finder, later split this on finders by Ref or Oid - # https://gitlab.com/gitlab-org/gitlab-foss/issues/39239 + # TODO Genericize finder, later split this on finders by Ref or Oid + # https://gitlab.com/gitlab-org/gitlab/issues/19877 def find_commit(oid_or_ref) commit = if oid_or_ref.is_a?(Gitlab::Git::Commit) oid_or_ref @@ -1136,7 +1140,7 @@ class Repository Gitlab::Git::Commit.find(raw_repository, oid_or_ref) end - ::Commit.new(commit, @project) if commit + ::Commit.new(commit, project) if commit end def cache diff --git a/app/models/resource_weight_event.rb b/app/models/resource_weight_event.rb new file mode 100644 index 00000000000..ab288798aed --- /dev/null +++ b/app/models/resource_weight_event.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class ResourceWeightEvent < ApplicationRecord + include Gitlab::Utils::StrongMemoize + + validates :user, presence: true + validates :issue, presence: true + + belongs_to :user + belongs_to :issue + + scope :by_issue, ->(issue) { where(issue_id: issue.id) } + scope :created_after, ->(time) { where('created_at > ?', time) } + + def discussion_id(resource = nil) + strong_memoize(:discussion_id) do + Digest::SHA1.hexdigest(discussion_id_key.join("-")) + end + end + + private + + def discussion_id_key + [self.class.name, created_at, user_id] + end +end diff --git a/app/models/sentry_issue.rb b/app/models/sentry_issue.rb index 6be52f99562..e60ad6015a5 100644 --- a/app/models/sentry_issue.rb +++ b/app/models/sentry_issue.rb @@ -4,7 +4,11 @@ class SentryIssue < ApplicationRecord belongs_to :issue validates :issue, uniqueness: true, presence: true - validates :sentry_issue_identifier, - uniqueness: true, - presence: true + validates :sentry_issue_identifier, presence: true + + def self.for_project_and_identifier(project, identifier) + joins(:issue) + .where(issues: { project_id: project.id }) + .find_by_sentry_issue_identifier(identifier) + end end diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 92746d28f05..b3b3de21dee 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -14,8 +14,12 @@ class Snippet < ApplicationRecord include Editable include Gitlab::SQL::Pattern include FromUnion + include IgnorableColumns + extend ::Gitlab::Utils::Override + ignore_column :storage_version, remove_with: '12.9', remove_after: '2020-03-22' + cache_markdown_field :title, pipeline: :single_line cache_markdown_field :description cache_markdown_field :content diff --git a/app/models/user.rb b/app/models/user.rb index ee42a987939..df54f358ffa 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -20,6 +20,7 @@ class User < ApplicationRecord include WithUploads include OptionallySearch include FromUnion + include BatchDestroyDependentAssociations DEFAULT_NOTIFICATION_LEVEL = :participating @@ -163,9 +164,9 @@ class User < ApplicationRecord # Validations # # Note: devise :validatable above adds validations for :email and :password - validates :name, presence: true, length: { maximum: 128 } - validates :first_name, length: { maximum: 255 } - validates :last_name, length: { maximum: 255 } + validates :name, presence: true, length: { maximum: 255 } + validates :first_name, length: { maximum: 127 } + validates :last_name, length: { maximum: 127 } validates :email, confirmation: true validates :notification_email, presence: true validates :notification_email, devise_email: true, if: ->(user) { user.notification_email != user.email } @@ -246,6 +247,7 @@ class User < ApplicationRecord delegate :show_whitespace_in_diffs, :show_whitespace_in_diffs=, to: :user_preference delegate :sourcegraph_enabled, :sourcegraph_enabled=, to: :user_preference delegate :setup_for_company, :setup_for_company=, to: :user_preference + delegate :render_whitespace_in_code, :render_whitespace_in_code=, to: :user_preference accepts_nested_attributes_for :user_preference, update_only: true @@ -285,6 +287,10 @@ class User < ApplicationRecord end end + before_transition do + !Gitlab::Database.read_only? + end + # rubocop: disable CodeReuse/ServiceClass # Ideally we should not call a service object here but user.block # is also bcalled by Users::MigrateToGhostUserService which references @@ -301,6 +307,8 @@ class User < ApplicationRecord scope :blocked, -> { with_states(:blocked, :ldap_blocked) } scope :external, -> { where(external: true) } scope :active, -> { with_state(:active).non_internal } + scope :active_without_ghosts, -> { with_state(:active).without_ghosts } + scope :without_ghosts, -> { where('ghost IS NOT TRUE') } scope :deactivated, -> { with_state(:deactivated).non_internal } scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) } scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) } @@ -464,7 +472,7 @@ class User < ApplicationRecord when 'deactivated' deactivated else - active + active_without_ghosts end end @@ -608,7 +616,7 @@ class User < ApplicationRecord end def self.non_internal - where('ghost IS NOT TRUE') + without_ghosts end # diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index a36f56089a0..713b0598029 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -13,6 +13,7 @@ class UserPreference < ApplicationRecord default_value_for :timezone, value: Time.zone.tzinfo.name, allows_nil: false default_value_for :time_display_relative, value: true, allows_nil: false default_value_for :time_format_in_24h, value: false, allows_nil: false + default_value_for :render_whitespace_in_code, value: false, allows_nil: false class << self def notes_filters diff --git a/app/policies/ci/trigger_policy.rb b/app/policies/ci/trigger_policy.rb index 578301d7f7e..e26f96a4b2b 100644 --- a/app/policies/ci/trigger_policy.rb +++ b/app/policies/ci/trigger_policy.rb @@ -5,13 +5,12 @@ module Ci delegate { @subject.project } with_options scope: :subject, score: 0 - condition(:legacy) { @subject.supports_legacy_tokens? && @subject.legacy? } with_score 0 condition(:is_owner) { @user && @subject.owner_id == @user.id } rule { ~can?(:admin_build) }.prevent :admin_trigger - rule { legacy | is_owner }.enable :admin_trigger + rule { is_owner }.enable :admin_trigger rule { can?(:admin_build) }.enable :manage_trigger end diff --git a/app/policies/grafana_integration_policy.rb b/app/policies/grafana_integration_policy.rb new file mode 100644 index 00000000000..529a1fe0493 --- /dev/null +++ b/app/policies/grafana_integration_policy.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class GrafanaIntegrationPolicy < BasePolicy + delegate { @subject.project } +end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 7b0297ea81b..e38eef527be 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -83,6 +83,11 @@ class ProjectPolicy < BasePolicy project.merge_requests_allowing_push_to_user(user).any? end + with_scope :subject + condition(:forking_allowed) do + @subject.feature_available?(:forking, @user) + end + with_scope :global condition(:mirror_available, score: 0) do ::Gitlab::CurrentSettings.current_application_settings.mirror_available @@ -203,7 +208,6 @@ class ProjectPolicy < BasePolicy enable :download_code enable :read_statistics enable :download_wiki_code - enable :fork_project enable :create_project_snippet enable :update_issue enable :reopen_issue @@ -232,12 +236,15 @@ class ProjectPolicy < BasePolicy enable :public_access enable :guest_access - enable :fork_project enable :build_download_code enable :build_read_container_image enable :request_access end + rule { (can?(:public_user_access) | can?(:reporter_access)) & forking_allowed }.policy do + enable :fork_project + end + rule { owner | admin | guest | group_member }.prevent :request_access rule { ~request_access_enabled }.prevent :request_access diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index d092a2de882..43f472b4c1d 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -25,3 +25,5 @@ class UserPolicy < BasePolicy rule { default }.enable :read_user_profile rule { (private_profile | blocked_user) & ~(user_is_self | admin) }.prevent :read_user_profile end + +UserPolicy.prepend_if_ee('EE::UserPolicy') diff --git a/app/presenters/ci/bridge_presenter.rb b/app/presenters/ci/bridge_presenter.rb index ee11cffe355..724e10c26c3 100644 --- a/app/presenters/ci/bridge_presenter.rb +++ b/app/presenters/ci/bridge_presenter.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Ci - class BridgePresenter < CommitStatusPresenter + class BridgePresenter < ProcessablePresenter def detailed_status @detailed_status ||= subject.detailed_status(user) end diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb index 33056a809b7..03cbb57eb84 100644 --- a/app/presenters/ci/build_presenter.rb +++ b/app/presenters/ci/build_presenter.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Ci - class BuildPresenter < CommitStatusPresenter + class BuildPresenter < ProcessablePresenter def erased_by_user? # Build can be erased through API, therefore it does not have # `erased_by` user assigned in that case. diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb index 8e469795581..33b7899f912 100644 --- a/app/presenters/ci/build_runner_presenter.rb +++ b/app/presenters/ci/build_runner_presenter.rb @@ -34,7 +34,7 @@ module Ci def refspecs specs = [] - specs << refspec_for_pipeline_ref if merge_request_ref? + specs << refspec_for_pipeline_ref if should_expose_merge_request_ref? specs << refspec_for_persistent_ref if persistent_ref_exist? if git_depth > 0 @@ -50,6 +50,19 @@ module Ci private + # We will stop exposing merge request refs when we fully depend on persistent refs + # (i.e. remove `refspec_for_pipeline_ref` when we remove `depend_on_persistent_pipeline_ref` feature flag.) + # `ci_force_exposing_merge_request_refs` is an extra feature flag that allows us to + # forcibly expose MR refs even if the `depend_on_persistent_pipeline_ref` feature flag enabled. + # This is useful when we see an unexpected behaviors/reports from users. + # See https://gitlab.com/gitlab-org/gitlab/issues/35140. + def should_expose_merge_request_ref? + return false unless merge_request_ref? + return true if Feature.enabled?(:ci_force_exposing_merge_request_refs, project) + + Feature.disabled?(:depend_on_persistent_pipeline_ref, project, default_enabled: true) + end + def create_archive(artifacts) return unless artifacts[:untracked] || artifacts[:paths] diff --git a/app/presenters/ci/processable_presenter.rb b/app/presenters/ci/processable_presenter.rb new file mode 100644 index 00000000000..5a8a6649071 --- /dev/null +++ b/app/presenters/ci/processable_presenter.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Ci + class ProcessablePresenter < CommitStatusPresenter + end +end diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb index 97771d84031..3ace27c72d5 100644 --- a/app/presenters/clusters/cluster_presenter.rb +++ b/app/presenters/clusters/cluster_presenter.rb @@ -10,11 +10,11 @@ module Clusters # We do not want to show the group path for clusters belonging to the # clusterable, only for the ancestor clusters. - def item_link(clusterable_presenter) + def item_link(clusterable_presenter, *html_options) if cluster.group_type? && clusterable != clusterable_presenter.subject contracted_group_name(cluster.group) + ' / ' + link_to_cluster else - link_to_cluster + link_to_cluster(*html_options) end end @@ -84,8 +84,8 @@ module Clusters sprite_icon('ellipsis_h', size: 12, css_class: 'vertical-align-middle') end - def link_to_cluster - link_to_if(can_read_cluster?, cluster.name, show_path) + def link_to_cluster(html_options: {}) + link_to_if(can_read_cluster?, cluster.name, show_path, html_options) end end end diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 81018398d5d..8c24d07675a 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -24,7 +24,8 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated commits_anchor_data, branches_anchor_data, tags_anchor_data, - files_anchor_data + files_anchor_data, + releases_anchor_data ].compact.select(&:is_link) end @@ -153,6 +154,22 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated empty_repo? ? nil : project_tree_path(project)) end + def releases_anchor_data + return unless can?(current_user, :read_release, project) + + releases_count = project.releases.count + return if releases_count < 1 + + AnchorData.new(true, + statistic_icon('rocket') + + n_('%{strong_start}%{release_count}%{strong_end} Release', '%{strong_start}%{release_count}%{strong_end} Releases', releases_count).html_safe % { + release_count: number_with_delimiter(releases_count), + strong_start: '<strong class="project-stat-value">'.html_safe, + strong_end: '</strong>'.html_safe + }, + project_releases_path(project)) + end + def commits_anchor_data AnchorData.new(true, statistic_icon('commit') + @@ -276,8 +293,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end def kubernetes_cluster_anchor_data - if current_user && can?(current_user, :create_cluster, project) - + if can_instantiate_cluster? if clusters.empty? AnchorData.new(false, statistic_icon + _('Add Kubernetes cluster'), @@ -294,7 +310,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end def gitlab_ci_anchor_data - if current_user && can_current_user_push_code? && repository.gitlab_ci_yml.blank? && !auto_devops_enabled? + if cicd_missing? AnchorData.new(false, statistic_icon + _('Set up CI/CD'), add_ci_yml_path) @@ -326,8 +342,28 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated count_of_extra_topics_not_shown > 0 end + def can_setup_review_app? + strong_memoize(:can_setup_review_app) do + (can_instantiate_cluster? && all_clusters_empty?) || cicd_missing? + end + end + + def all_clusters_empty? + strong_memoize(:all_clusters_empty) do + project.all_clusters.empty? + end + end + private + def cicd_missing? + current_user && can_current_user_push_code? && repository.gitlab_ci_yml.blank? && !auto_devops_enabled? + end + + def can_instantiate_cluster? + current_user && can?(current_user, :create_cluster, project) + end + def filename_path(filename) if blob = repository.public_send(filename) # rubocop:disable GitlabSecurity/PublicSend project_blob_path( diff --git a/app/serializers/build_artifact_entity.rb b/app/serializers/build_artifact_entity.rb index 414f436e76e..d1750695523 100644 --- a/app/serializers/build_artifact_entity.rb +++ b/app/serializers/build_artifact_entity.rb @@ -2,6 +2,7 @@ class BuildArtifactEntity < Grape::Entity include RequestAwareEntity + include GitlabRoutingHelper expose :name do |job| job.name @@ -11,15 +12,15 @@ class BuildArtifactEntity < Grape::Entity expose :artifacts_expire_at, as: :expire_at expose :path do |job| - download_project_job_artifacts_path(project, job) + fast_download_project_job_artifacts_path(project, job) end expose :keep_path, if: -> (*) { job.has_expiring_artifacts? } do |job| - keep_project_job_artifacts_path(project, job) + fast_keep_project_job_artifacts_path(project, job) end expose :browse_path do |job| - browse_project_job_artifacts_path(project, job) + fast_browse_project_job_artifacts_path(project, job) end private diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb index 218bdd21e37..632718df780 100644 --- a/app/serializers/cluster_application_entity.rb +++ b/app/serializers/cluster_application_entity.rb @@ -8,9 +8,9 @@ class ClusterApplicationEntity < Grape::Entity expose :external_ip, if: -> (e, _) { e.respond_to?(:external_ip) } expose :external_hostname, if: -> (e, _) { e.respond_to?(:external_hostname) } expose :hostname, if: -> (e, _) { e.respond_to?(:hostname) } - expose :kibana_hostname, if: -> (e, _) { e.respond_to?(:kibana_hostname) } expose :email, if: -> (e, _) { e.respond_to?(:email) } expose :stack, if: -> (e, _) { e.respond_to?(:stack) } + expose :modsecurity_enabled, if: -> (e, _) { e.respond_to?(:modsecurity_enabled) } expose :update_available?, as: :update_available, if: -> (e, _) { e.respond_to?(:update_available?) } expose :can_uninstall?, as: :can_uninstall end diff --git a/app/serializers/deploy_key_entity.rb b/app/serializers/deploy_key_entity.rb index 2682a47fbaa..653316ce4d2 100644 --- a/app/serializers/deploy_key_entity.rb +++ b/app/serializers/deploy_key_entity.rb @@ -5,6 +5,7 @@ class DeployKeyEntity < Grape::Entity expose :user_id expose :title expose :fingerprint + expose :fingerprint_sha256 expose :destroyed_when_orphaned?, as: :destroyed_when_orphaned expose :almost_orphaned?, as: :almost_orphaned expose :created_at diff --git a/app/serializers/error_tracking/detailed_error_entity.rb b/app/serializers/error_tracking/detailed_error_entity.rb index dd0cac8e4cd..d3b38a24316 100644 --- a/app/serializers/error_tracking/detailed_error_entity.rb +++ b/app/serializers/error_tracking/detailed_error_entity.rb @@ -8,6 +8,8 @@ module ErrorTracking :external_url, :first_release_last_commit, :first_release_short_version, + :gitlab_commit, + :gitlab_commit_path, :first_seen, :frequency, :gitlab_issue, @@ -21,6 +23,7 @@ module ErrorTracking :project_slug, :short_id, :status, + :tags, :title, :type, :user_count diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb index 71589ac8315..a4ab1d399bc 100644 --- a/app/serializers/pipeline_details_entity.rb +++ b/app/serializers/pipeline_details_entity.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class PipelineDetailsEntity < PipelineEntity + expose :project, using: ProjectEntity + expose :flags do expose :latest?, as: :latest end diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb index 6b2a1bfe666..ba8f4fffe02 100644 --- a/app/serializers/pipeline_entity.rb +++ b/app/serializers/pipeline_entity.rb @@ -77,6 +77,10 @@ class PipelineEntity < Grape::Entity cancel_project_pipeline_path(pipeline.project, pipeline) end + expose :delete_path, if: -> (*) { can_delete? } do |pipeline| + project_pipeline_path(pipeline.project, pipeline) + end + expose :failed_builds, if: -> (*) { can_retry? }, using: JobEntity do |pipeline| pipeline.failed_builds end @@ -95,6 +99,10 @@ class PipelineEntity < Grape::Entity pipeline.cancelable? end + def can_delete? + can?(request.current_user, :destroy_pipeline, pipeline) + end + def has_presentable_merge_request? pipeline.triggered_by_merge_request? && can?(request.current_user, :read_merge_request, pipeline.merge_request) diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb index b25a1ea9209..be535a5d414 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -41,6 +41,7 @@ class PipelineSerializer < BaseSerializer def preloaded_relations [ :latest_statuses_ordered_by_stage, + :project, :stages, { failed_builds: %i(project metadata) diff --git a/app/serializers/review_app_setup_entity.rb b/app/serializers/review_app_setup_entity.rb new file mode 100644 index 00000000000..3a21fe24d9e --- /dev/null +++ b/app/serializers/review_app_setup_entity.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class ReviewAppSetupEntity < Grape::Entity + include RequestAwareEntity + + expose :can_setup_review_app?, as: :can_setup_review_app + + expose :all_clusters_empty?, as: :all_clusters_empty, if: -> (_, _) { project.can_setup_review_app? } do |project| + project.all_clusters_empty? + end + + expose :review_snippet, if: -> (_, _) { project.can_setup_review_app? } do |_| + YAML.safe_load(File.read(Rails.root.join('lib', 'gitlab', 'ci', 'snippets', 'review_app_default.yml'))).to_s + end + + private + + def current_user + request.current_user + end + + def project + object + end +end diff --git a/app/serializers/review_app_setup_serializer.rb b/app/serializers/review_app_setup_serializer.rb new file mode 100644 index 00000000000..4baec7679b0 --- /dev/null +++ b/app/serializers/review_app_setup_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ReviewAppSetupSerializer < BaseSerializer + entity ReviewAppSetupEntity +end diff --git a/app/serializers/suggestion_entity.rb b/app/serializers/suggestion_entity.rb index 2dd62e19e29..4fb19fbc074 100644 --- a/app/serializers/suggestion_entity.rb +++ b/app/serializers/suggestion_entity.rb @@ -4,7 +4,9 @@ class SuggestionEntity < API::Entities::Suggestion include RequestAwareEntity unexpose :from_line, :to_line, :from_content, :to_content - expose :diff_lines, using: DiffLineEntity + expose :diff_lines, using: DiffLineEntity do |suggestion| + Gitlab::Diff::Highlight.new(suggestion.diff_lines).highlight + end expose :current_user do expose :can_apply do |suggestion| Ability.allowed?(current_user, :apply_suggestion, suggestion) diff --git a/app/services/akismet_service.rb b/app/services/akismet_service.rb index 63be3c371ec..d8098c4a8f5 100644 --- a/app/services/akismet_service.rb +++ b/app/services/akismet_service.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true class AkismetService - attr_accessor :owner, :text, :options + attr_accessor :text, :options - def initialize(owner, text, options = {}) - @owner = owner + def initialize(owner_name, owner_email, text, options = {}) + @owner_name = owner_name + @owner_email = owner_email @text = text @options = options end @@ -16,8 +17,8 @@ class AkismetService type: 'comment', text: text, created_at: DateTime.now, - author: owner.name, - author_email: owner.email, + author: owner_name, + author_email: owner_email, referrer: options[:referrer] } @@ -40,6 +41,8 @@ class AkismetService private + attr_accessor :owner_name, :owner_email + def akismet_client @akismet_client ||= ::Akismet::Client.new(Gitlab::CurrentSettings.akismet_api_key, Gitlab.config.gitlab.url) @@ -55,8 +58,8 @@ class AkismetService params = { type: 'comment', text: text, - author: owner.name, - author_email: owner.email + author: owner_name, + author_email: owner_email } begin diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb index 37a74cd1b00..a9240e1d8a0 100644 --- a/app/services/boards/issues/list_service.rb +++ b/app/services/boards/issues/list_service.rb @@ -5,6 +5,10 @@ module Boards class ListService < Boards::BaseService include Gitlab::Utils::StrongMemoize + def self.valid_params + IssuesFinder.valid_params + end + def execute fetch_issues.order_by_position_and_priority end diff --git a/app/services/boards/list_service.rb b/app/services/boards/list_service.rb index 44d5a21b15f..8258d5d07d3 100644 --- a/app/services/boards/list_service.rb +++ b/app/services/boards/list_service.rb @@ -4,13 +4,24 @@ module Boards class ListService < Boards::BaseService def execute create_board! if parent.boards.empty? - boards + + if parent.multiple_issue_boards_available? + boards + else + # When multiple issue boards are not available + # a user is only allowed to view the default shown board + first_board + end end private def boards - parent.boards + parent.boards.order_by_name_asc + end + + def first_board + parent.boards.first_board end def create_board! @@ -18,5 +29,3 @@ module Boards end end end - -Boards::ListService.prepend_if_ee('EE::Boards::ListService') diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index ce3a9eb0772..2daf3a51235 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -23,7 +23,7 @@ module Ci Gitlab::Ci::Pipeline::Chain::Limit::JobActivity].freeze # rubocop: disable Metrics/ParameterLists - def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, external_pull_request: nil, **options, &block) + def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, external_pull_request: nil, bridge: nil, **options, &block) @pipeline = Ci::Pipeline.new command = Gitlab::Ci::Pipeline::Chain::Command.new( @@ -46,6 +46,7 @@ module Ci current_user: current_user, push_options: params[:push_options] || {}, chat_data: params[:chat_data], + bridge: bridge, **extra_options(options)) sequence = Gitlab::Ci::Pipeline::Chain::Sequence @@ -104,14 +105,14 @@ module Ci if Feature.enabled?(:ci_support_interruptible_pipelines, project, default_enabled: true) project.ci_pipelines .where(ref: pipeline.ref) - .where.not(id: pipeline.id) + .where.not(id: pipeline.same_family_pipeline_ids) .where.not(sha: project.commit(pipeline.ref).try(:id)) .alive_or_scheduled .with_only_interruptible_builds else project.ci_pipelines .where(ref: pipeline.ref) - .where.not(id: pipeline.id) + .where.not(id: pipeline.same_family_pipeline_ids) .where.not(sha: project.commit(pipeline.ref).try(:id)) .created_or_pending end diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb new file mode 100644 index 00000000000..1ed295f5950 --- /dev/null +++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +module Ci + module PipelineProcessing + class AtomicProcessingService + include Gitlab::Utils::StrongMemoize + include ExclusiveLeaseGuard + + attr_reader :pipeline + + DEFAULT_LEASE_TIMEOUT = 1.minute + BATCH_SIZE = 20 + + def initialize(pipeline) + @pipeline = pipeline + @collection = AtomicProcessingService::StatusCollection.new(pipeline) + end + + def execute + return unless pipeline.needs_processing? + + success = try_obtain_lease { process! } + + # re-schedule if we need further processing + if success && pipeline.needs_processing? + PipelineProcessWorker.perform_async(pipeline.id) + end + + success + end + + private + + def process! + update_stages! + update_pipeline! + update_statuses_processed! + + true + end + + def update_stages! + pipeline.stages.ordered.each(&method(:update_stage!)) + end + + def update_stage!(stage) + # Update processables for a given stage in bulk/slices + ids = @collection.created_processable_ids_for_stage_position(stage.position) + ids.in_groups_of(BATCH_SIZE, false, &method(:update_processables!)) + + status = @collection.status_for_stage_position(stage.position) + stage.set_status(status) + end + + def update_processables!(ids) + created_processables = pipeline.processables.for_ids(ids) + .with_project_preload + .created + .latest + .ordered_by_stage + .select_with_aggregated_needs(project) + + created_processables.each(&method(:update_processable!)) + end + + def update_pipeline! + pipeline.set_status(@collection.status_of_all) + end + + def update_statuses_processed! + processing = @collection.processing_processables + processing.each_slice(BATCH_SIZE) do |slice| + pipeline.statuses.match_id_and_lock_version(slice) + .update_as_processed! + end + end + + def update_processable!(processable) + status = processable_status(processable) + return unless HasStatus::COMPLETED_STATUSES.include?(status) + + # transition status if possible + Gitlab::OptimisticLocking.retry_lock(processable) do |subject| + Ci::ProcessBuildService.new(project, subject.user) + .execute(subject, status) + + # update internal representation of status + # to make the status change of processable + # to be taken into account during further processing + @collection.set_processable_status( + processable.id, processable.status, processable.lock_version) + end + end + + def processable_status(processable) + if needs_names = processable.aggregated_needs_names + # Processable uses DAG, get status of all dependent needs + @collection.status_for_names(needs_names) + else + # Processable uses Stages, get status of prior stage + @collection.status_for_prior_stage_position(processable.stage_idx.to_i) + end + end + + def project + pipeline.project + end + + def lease_key + "#{super}::pipeline_id:#{pipeline.id}" + end + + def lease_timeout + DEFAULT_LEASE_TIMEOUT + end + end + end +end diff --git a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb new file mode 100644 index 00000000000..42e38a5c80f --- /dev/null +++ b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +module Ci + module PipelineProcessing + class AtomicProcessingService + class StatusCollection + include Gitlab::Utils::StrongMemoize + + attr_reader :pipeline + + # We use these columns to perform an efficient + # calculation of a status + STATUSES_COLUMNS = [ + :id, :name, :status, :allow_failure, + :stage_idx, :processed, :lock_version + ].freeze + + def initialize(pipeline) + @pipeline = pipeline + @stage_statuses = {} + @prior_stage_statuses = {} + end + + # This method updates internal status for given ID + def set_processable_status(id, status, lock_version) + processable = all_statuses_by_id[id] + return unless processable + + processable[:status] = status + processable[:lock_version] = lock_version + end + + # This methods gets composite status of all processables + def status_of_all + status_for_array(all_statuses) + end + + # This methods gets composite status for processables with given names + def status_for_names(names) + name_statuses = all_statuses_by_name.slice(*names) + + status_for_array(name_statuses.values) + end + + # This methods gets composite status for processables before given stage + def status_for_prior_stage_position(position) + strong_memoize("status_for_prior_stage_position_#{position}") do + stage_statuses = all_statuses_grouped_by_stage_position + .select { |stage_position, _| stage_position < position } + + status_for_array(stage_statuses.values.flatten) + end + end + + # This methods gets a list of processables for a given stage + def created_processable_ids_for_stage_position(current_position) + all_statuses_grouped_by_stage_position[current_position] + .to_a + .select { |processable| processable[:status] == 'created' } + .map { |processable| processable[:id] } + end + + # This methods gets composite status for processables at a given stage + def status_for_stage_position(current_position) + strong_memoize("status_for_stage_position_#{current_position}") do + stage_statuses = all_statuses_grouped_by_stage_position[current_position].to_a + + status_for_array(stage_statuses.flatten) + end + end + + # This method returns a list of all processable, that are to be processed + def processing_processables + all_statuses.lazy.reject { |status| status[:processed] } + end + + private + + def status_for_array(statuses) + result = Gitlab::Ci::Status::Composite + .new(statuses) + .status + result || 'success' + end + + def all_statuses_grouped_by_stage_position + strong_memoize(:all_statuses_by_order) do + all_statuses.group_by { |status| status[:stage_idx].to_i } + end + end + + def all_statuses_by_id + strong_memoize(:all_statuses_by_id) do + all_statuses.map do |row| + [row[:id], row] + end.to_h + end + end + + def all_statuses_by_name + strong_memoize(:statuses_by_name) do + all_statuses.map do |row| + [row[:name], row] + end.to_h + end + end + + # rubocop: disable CodeReuse/ActiveRecord + def all_statuses + # We fetch all relevant data in one go. + # + # This is more efficient than relying + # on PostgreSQL to calculate composite status + # for us + # + # Since we need to reprocess everything + # we can fetch all of them and do processing + # ourselves. + strong_memoize(:all_statuses) do + raw_statuses = pipeline + .statuses + .latest + .ordered_by_stage + .pluck(*STATUSES_COLUMNS) + + raw_statuses.map do |row| + STATUSES_COLUMNS.zip(row).to_h + end + end + end + # rubocop: enable CodeReuse/ActiveRecord + end + end + end +end diff --git a/app/services/ci/pipeline_processing/legacy_processing_service.rb b/app/services/ci/pipeline_processing/legacy_processing_service.rb new file mode 100644 index 00000000000..400dc9f0abb --- /dev/null +++ b/app/services/ci/pipeline_processing/legacy_processing_service.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +module Ci + module PipelineProcessing + class LegacyProcessingService + include Gitlab::Utils::StrongMemoize + + attr_reader :pipeline + + def initialize(pipeline) + @pipeline = pipeline + end + + def execute(trigger_build_ids = nil) + success = process_stages_without_needs + + # we evaluate dependent needs, + # only when the another job has finished + success = process_builds_with_needs(trigger_build_ids) || success + + @pipeline.update_legacy_status + + success + end + + private + + def process_stages_without_needs + stage_indexes_of_created_processables_without_needs.flat_map do |index| + process_stage_without_needs(index) + end.any? + end + + def process_stage_without_needs(index) + current_status = status_for_prior_stages(index) + + return unless HasStatus::COMPLETED_STATUSES.include?(current_status) + + created_processables_in_stage_without_needs(index).find_each.select do |build| + process_build(build, current_status) + end.any? + end + + def process_builds_with_needs(trigger_build_ids) + return false unless trigger_build_ids.present? + return false unless Feature.enabled?(:ci_dag_support, project, default_enabled: true) + + # we find processables that are dependent: + # 1. because of current dependency, + trigger_build_names = pipeline.processables.latest + .for_ids(trigger_build_ids).names + + # 2. does not have builds that not yet complete + incomplete_build_names = pipeline.processables.latest + .incomplete.names + + # Each found processable is guaranteed here to have completed status + created_processables + .with_needs(trigger_build_names) + .without_needs(incomplete_build_names) + .find_each + .map(&method(:process_build_with_needs)) + .any? + end + + def process_build_with_needs(build) + current_status = status_for_build_needs(build.needs.map(&:name)) + + return unless HasStatus::COMPLETED_STATUSES.include?(current_status) + + process_build(build, current_status) + end + + def process_build(build, current_status) + Gitlab::OptimisticLocking.retry_lock(build) do |subject| + Ci::ProcessBuildService.new(project, subject.user) + .execute(subject, current_status) + end + end + + def status_for_prior_stages(index) + pipeline.processables.status_for_prior_stages(index) + end + + def status_for_build_needs(needs) + pipeline.processables.status_for_names(needs) + end + + # rubocop: disable CodeReuse/ActiveRecord + def stage_indexes_of_created_processables_without_needs + created_processables_without_needs.order(:stage_idx) + .pluck(Arel.sql('DISTINCT stage_idx')) + end + # rubocop: enable CodeReuse/ActiveRecord + + def created_processables_in_stage_without_needs(index) + created_processables_without_needs + .with_preloads + .for_stage(index) + end + + def created_processables_without_needs + if Feature.enabled?(:ci_dag_support, project, default_enabled: true) + pipeline.processables.created.without_needs + else + pipeline.processables.created + end + end + + def created_processables + pipeline.processables.created + end + + def project + pipeline.project + end + end + end +end diff --git a/app/services/ci/prepare_build_service.rb b/app/services/ci/prepare_build_service.rb index 5d024c45e5f..3f87c711270 100644 --- a/app/services/ci/prepare_build_service.rb +++ b/app/services/ci/prepare_build_service.rb @@ -11,7 +11,7 @@ module Ci def execute prerequisites.each(&:complete!) - build.enqueue! + build.enqueue_preparing! rescue => e Gitlab::ErrorTracking.track_exception(e, build_id: build.id) diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb index f33cbf7ab29..1ecef256233 100644 --- a/app/services/ci/process_pipeline_service.rb +++ b/app/services/ci/process_pipeline_service.rb @@ -2,8 +2,6 @@ module Ci class ProcessPipelineService - include Gitlab::Utils::StrongMemoize - attr_reader :pipeline def initialize(pipeline) @@ -13,104 +11,18 @@ module Ci def execute(trigger_build_ids = nil) update_retried - success = process_stages_without_needs - - # we evaluate dependent needs, - # only when the another job has finished - success = process_builds_with_needs(trigger_build_ids) || success - - @pipeline.update_status - - success - end - - private - - def process_stages_without_needs - stage_indexes_of_created_processables_without_needs.flat_map do |index| - process_stage_without_needs(index) - end.any? - end - - def process_stage_without_needs(index) - current_status = status_for_prior_stages(index) - - return unless HasStatus::COMPLETED_STATUSES.include?(current_status) - - created_processables_in_stage_without_needs(index).find_each.select do |build| - process_build(build, current_status) - end.any? - end - - def process_builds_with_needs(trigger_build_ids) - return false unless trigger_build_ids.present? - return false unless Feature.enabled?(:ci_dag_support, project, default_enabled: true) - - # we find processables that are dependent: - # 1. because of current dependency, - trigger_build_names = pipeline.processables.latest - .for_ids(trigger_build_ids).names - - # 2. does not have builds that not yet complete - incomplete_build_names = pipeline.processables.latest - .incomplete.names - - # Each found processable is guaranteed here to have completed status - created_processables - .with_needs(trigger_build_names) - .without_needs(incomplete_build_names) - .find_each - .map(&method(:process_build_with_needs)) - .any? - end - - def process_build_with_needs(build) - current_status = status_for_build_needs(build.needs.map(&:name)) - - return unless HasStatus::COMPLETED_STATUSES.include?(current_status) - - process_build(build, current_status) - end - - def process_build(build, current_status) - Gitlab::OptimisticLocking.retry_lock(build) do |subject| - Ci::ProcessBuildService.new(project, build.user) - .execute(subject, current_status) - end - end - - def status_for_prior_stages(index) - pipeline.processables.status_for_prior_stages(index) - end - - def status_for_build_needs(needs) - pipeline.processables.status_for_names(needs) - end - - # rubocop: disable CodeReuse/ActiveRecord - def stage_indexes_of_created_processables_without_needs - created_processables_without_needs.order(:stage_idx) - .pluck(Arel.sql('DISTINCT stage_idx')) - end - # rubocop: enable CodeReuse/ActiveRecord - - def created_processables_in_stage_without_needs(index) - created_processables_without_needs - .with_preloads - .for_stage(index) - end - - def created_processables_without_needs - if Feature.enabled?(:ci_dag_support, project, default_enabled: true) - pipeline.processables.created.without_needs + if Feature.enabled?(:ci_atomic_processing, pipeline.project) + Ci::PipelineProcessing::AtomicProcessingService + .new(pipeline) + .execute else - pipeline.processables.created + Ci::PipelineProcessing::LegacyProcessingService + .new(pipeline) + .execute(trigger_build_ids) end end - def created_processables - pipeline.processables.created - end + private # This method is for compatibility and data consistency and should be removed with 9.3 version of GitLab # This replicates what is db/post_migrate/20170416103934_upate_retried_for_ci_build.rb @@ -131,9 +43,5 @@ module Ci .update_all(retried: true) if latest_statuses.any? end # rubocop: enable CodeReuse/ActiveRecord - - def project - pipeline.project - end end end diff --git a/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb b/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb new file mode 100644 index 00000000000..a4bcca8e8b3 --- /dev/null +++ b/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Ci + module ResourceGroups + class AssignResourceFromResourceGroupService < ::BaseService + # rubocop: disable CodeReuse/ActiveRecord + def execute(resource_group) + free_resources = resource_group.resources.free.count + + resource_group.builds.waiting_for_resource.take(free_resources).each do |build| + build.enqueue_waiting_for_resource + end + end + # rubocop: enable CodeReuse/ActiveRecord + end + end +end diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index 7a5e33c61ba..1f00d54b6a7 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -5,13 +5,13 @@ module Ci CLONE_ACCESSORS = %i[pipeline project ref tag options name allow_failure stage stage_id stage_idx trigger_request yaml_variables when environment coverage_regex - description tag_list protected needs].freeze + description tag_list protected needs resource_group].freeze def execute(build) reprocess!(build).tap do |new_build| build.pipeline.mark_as_processable_after_stage(build.stage_idx) - new_build.enqueue! + Gitlab::OptimisticLocking.retry_lock(new_build, &:enqueue) MergeRequests::AddTodoWhenBuildFailsService .new(project, current_user) @@ -31,15 +31,17 @@ module Ci attributes.push([:user, current_user]) - build.retried = true - Ci::Build.transaction do # mark all other builds of that name as retried build.pipeline.builds.latest .where(name: build.name) - .update_all(retried: true) + .update_all(retried: true, processed: true) - create_build!(attributes) + create_build!(attributes).tap do + # mark existing object as retried/processed without a reload + build.retried = true + build.processed = true + end end end # rubocop: enable CodeReuse/ActiveRecord @@ -49,6 +51,7 @@ module Ci def create_build!(attributes) build = project.builds.new(Hash[attributes]) build.deployment = ::Gitlab::Ci::Pipeline::Seed::Deployment.new(build).to_resource + build.retried = false build.save! build end diff --git a/app/services/clusters/applications/base_service.rb b/app/services/clusters/applications/base_service.rb index c9f7917938f..844da11e5cb 100644 --- a/app/services/clusters/applications/base_service.rb +++ b/app/services/clusters/applications/base_service.rb @@ -19,10 +19,6 @@ module Clusters application.hostname = params[:hostname] end - if application.has_attribute?(:kibana_hostname) - application.kibana_hostname = params[:kibana_hostname] - end - if application.has_attribute?(:email) application.email = params[:email] end @@ -31,6 +27,10 @@ module Clusters application.stack = params[:stack] end + if application.has_attribute?(:modsecurity_enabled) + application.modsecurity_enabled = params[:modsecurity_enabled] || false + end + if application.respond_to?(:oauth_application) application.oauth_application = create_oauth_application(application, request) end @@ -68,7 +68,7 @@ module Clusters end def invalid_application? - unknown_application? || (application_name == Applications::ElasticStack.application_name && !Feature.enabled?(:enable_cluster_application_elastic_stack)) || (application_name == Applications::Crossplane.application_name && !Feature.enabled?(:enable_cluster_application_crossplane)) + unknown_application? || (application_name == Applications::ElasticStack.application_name && !Feature.enabled?(:enable_cluster_application_elastic_stack)) end def unknown_application? diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb index 1ce6e0c1cb0..7d064abfaa3 100644 --- a/app/services/clusters/applications/check_installation_progress_service.rb +++ b/app/services/clusters/applications/check_installation_progress_service.rb @@ -11,6 +11,8 @@ module Clusters def on_success app.make_installed! + + Gitlab::Tracking.event('cluster:applications', "cluster_application_#{app.name}_installed") ensure remove_installation_pod end diff --git a/app/services/clusters/kubernetes/create_or_update_namespace_service.rb b/app/services/clusters/kubernetes/create_or_update_namespace_service.rb index 15be8446cc0..c6c7eb99bf3 100644 --- a/app/services/clusters/kubernetes/create_or_update_namespace_service.rb +++ b/app/services/clusters/kubernetes/create_or_update_namespace_service.rb @@ -21,10 +21,15 @@ module Clusters attr_reader :cluster, :kubernetes_namespace, :platform def create_project_service_account + environment_slug = kubernetes_namespace.environment&.slug + namespace_labels = { 'app.gitlab.com/app' => kubernetes_namespace.project.full_path_slug } + namespace_labels['app.gitlab.com/env'] = environment_slug if environment_slug + Clusters::Kubernetes::CreateOrUpdateServiceAccountService.namespace_creator( platform.kubeclient, service_account_name: kubernetes_namespace.service_account_name, service_account_namespace: kubernetes_namespace.namespace, + service_account_namespace_labels: namespace_labels, rbac: platform.rbac? ).execute end diff --git a/app/services/clusters/kubernetes/create_or_update_service_account_service.rb b/app/services/clusters/kubernetes/create_or_update_service_account_service.rb index d798dcdcfd3..b1820474c9d 100644 --- a/app/services/clusters/kubernetes/create_or_update_service_account_service.rb +++ b/app/services/clusters/kubernetes/create_or_update_service_account_service.rb @@ -3,10 +3,11 @@ module Clusters module Kubernetes class CreateOrUpdateServiceAccountService - def initialize(kubeclient, service_account_name:, service_account_namespace:, token_name:, rbac:, namespace_creator: false, role_binding_name: nil) + def initialize(kubeclient, service_account_name:, service_account_namespace:, service_account_namespace_labels: nil, token_name:, rbac:, namespace_creator: false, role_binding_name: nil) @kubeclient = kubeclient @service_account_name = service_account_name @service_account_namespace = service_account_namespace + @service_account_namespace_labels = service_account_namespace_labels @token_name = token_name @rbac = rbac @namespace_creator = namespace_creator @@ -23,11 +24,12 @@ module Clusters ) end - def self.namespace_creator(kubeclient, service_account_name:, service_account_namespace:, rbac:) + def self.namespace_creator(kubeclient, service_account_name:, service_account_namespace:, service_account_namespace_labels:, rbac:) self.new( kubeclient, service_account_name: service_account_name, service_account_namespace: service_account_namespace, + service_account_namespace_labels: service_account_namespace_labels, token_name: "#{service_account_namespace}-token", rbac: rbac, namespace_creator: true, @@ -55,12 +57,13 @@ module Clusters private - attr_reader :kubeclient, :service_account_name, :service_account_namespace, :token_name, :rbac, :namespace_creator, :role_binding_name + attr_reader :kubeclient, :service_account_name, :service_account_namespace, :service_account_namespace_labels, :token_name, :rbac, :namespace_creator, :role_binding_name def ensure_project_namespace_exists Gitlab::Kubernetes::Namespace.new( service_account_namespace, - kubeclient + kubeclient, + labels: service_account_namespace_labels ).ensure_exists! end diff --git a/app/services/concerns/akismet_methods.rb b/app/services/concerns/akismet_methods.rb new file mode 100644 index 00000000000..1cbcf0d47b9 --- /dev/null +++ b/app/services/concerns/akismet_methods.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module AkismetMethods + def spammable_owner + @user ||= User.find(spammable_owner_id) + end + + def spammable_owner_id + @owner_id ||= + if spammable.respond_to?(:author_id) + spammable.author_id + elsif spammable.respond_to?(:creator_id) + spammable.creator_id + end + end + + def akismet + @akismet ||= AkismetService.new( + spammable_owner.name, + spammable_owner.email, + spammable.spammable_text, + options + ) + end +end diff --git a/app/services/spam_check_service.rb b/app/services/concerns/spam_check_methods.rb index 51d300d4f1d..75d9759f1d1 100644 --- a/app/services/spam_check_service.rb +++ b/app/services/concerns/spam_check_methods.rb @@ -1,14 +1,14 @@ # frozen_string_literal: true -# SpamCheckService +# SpamCheckMethods # # Provide helper methods for checking if a given spammable object has # potential spam data. # # Dependencies: # - params with :request -# -module SpamCheckService + +module SpamCheckMethods # rubocop:disable Gitlab/ModuleWithInstanceVariables def filter_spam_check_params @request = params.delete(:request) @@ -24,7 +24,7 @@ module SpamCheckService # rubocop:disable Gitlab/ModuleWithInstanceVariables # rubocop: disable CodeReuse/ActiveRecord def spam_check(spammable, user) - spam_service = SpamService.new(spammable, @request) + spam_service = SpamService.new(spammable: spammable, request: @request) spam_service.when_recaptcha_verified(@recaptcha_verified, @api) do user.spam_logs.find_by(id: @spam_log_id)&.update!(recaptcha_verified: true) diff --git a/app/services/container_expiration_policy_service.rb b/app/services/container_expiration_policy_service.rb new file mode 100644 index 00000000000..5d141d4d64d --- /dev/null +++ b/app/services/container_expiration_policy_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ContainerExpirationPolicyService < BaseService + def execute(container_expiration_policy) + container_expiration_policy.schedule_next_run! + + container_expiration_policy.container_repositories.find_each do |container_repository| + CleanupContainerRepositoryWorker.perform_async( + current_user.id, + container_repository.id, + container_expiration_policy.attributes.except("created_at", "updated_at") + ) + end + end +end diff --git a/app/services/create_snippet_service.rb b/app/services/create_snippet_service.rb deleted file mode 100644 index eacea7d94c7..00000000000 --- a/app/services/create_snippet_service.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -class CreateSnippetService < BaseService - include SpamCheckService - - def execute - filter_spam_check_params - - snippet = if project - project.snippets.build(params) - else - PersonalSnippet.new(params) - end - - unless Gitlab::VisibilityLevel.allowed_for?(current_user, snippet.visibility_level) - deny_visibility_level(snippet) - return snippet - end - - snippet.author = current_user - - spam_check(snippet, current_user) - - snippet_saved = snippet.with_transaction_returning_status do - snippet.save && snippet.store_mentions! - end - - if snippet_saved - UserAgentDetailService.new(snippet, @request).create - Gitlab::UsageDataCounters::SnippetCounter.count(:create) - end - - snippet - end -end diff --git a/app/services/deployments/after_create_service.rb b/app/services/deployments/after_create_service.rb index 1d9cb666cff..3560f9c983b 100644 --- a/app/services/deployments/after_create_service.rb +++ b/app/services/deployments/after_create_service.rb @@ -34,21 +34,12 @@ module Deployments if environment.save && !environment.stopped? deployment.update_merge_request_metrics! - link_merge_requests(deployment) end end end private - def link_merge_requests(deployment) - unless Feature.enabled?(:deployment_merge_requests, deployment.project) - return - end - - LinkMergeRequestsService.new(deployment).execute - end - def environment_options options&.dig(:environment) || {} end diff --git a/app/services/deployments/link_merge_requests_service.rb b/app/services/deployments/link_merge_requests_service.rb index 71186659290..a1d6d50bbb4 100644 --- a/app/services/deployments/link_merge_requests_service.rb +++ b/app/services/deployments/link_merge_requests_service.rb @@ -13,7 +13,10 @@ module Deployments end def execute - return unless deployment.success? + # Review apps have the environment type set (e.g. to `review`, though the + # exact value may differ). We don't want to link merge requests to review + # app deployments, as this is not useful. + return if deployment.environment.environment_type if (prev = deployment.previous_environment_deployment) link_merge_requests_for_range(prev.sha, deployment.sha) diff --git a/app/services/error_tracking/issue_update_service.rb b/app/services/error_tracking/issue_update_service.rb new file mode 100644 index 00000000000..e433b4a11f2 --- /dev/null +++ b/app/services/error_tracking/issue_update_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module ErrorTracking + class IssueUpdateService < ErrorTracking::BaseService + private + + def fetch + project_error_tracking_setting.update_issue( + issue_id: params[:issue_id], + params: update_params + ) + end + + def update_params + params.except(:issue_id) + end + + def parse_response(response) + { updated: response[:updated].present? } + end + end +end diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb index f7282c22a52..7460f0df535 100644 --- a/app/services/event_create_service.rb +++ b/app/services/event_create_service.rb @@ -101,7 +101,7 @@ class EventCreateService Users::LastPushEventService.new(current_user) .cache_last_push_event(event) - Users::ActivityService.new(current_user, 'push').execute + Users::ActivityService.new(current_user).execute end def create_event(resource_parent, current_user, status, attributes = {}) diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb index d935d9e8cdc..a49983a84fc 100644 --- a/app/services/git/base_hooks_service.rb +++ b/app/services/git/base_hooks_service.rb @@ -163,7 +163,7 @@ module Git end def logger - if Sidekiq.server? + if Gitlab::Runtime.sidekiq? Sidekiq.logger else # This service runs in Sidekiq, so this shouldn't ever be diff --git a/app/services/ham_service.rb b/app/services/ham_service.rb index 794eb34d9ca..0bbdaa47a1b 100644 --- a/app/services/ham_service.rb +++ b/app/services/ham_service.rb @@ -18,8 +18,10 @@ class HamService private def akismet + user = spam_log.user @akismet ||= AkismetService.new( - spam_log.user, + user.name, + user.email, spam_log.text, ip_address: spam_log.source_ip, user_agent: spam_log.user_agent diff --git a/app/services/issuable/clone/attributes_rewriter.rb b/app/services/issuable/clone/attributes_rewriter.rb index 1f5d83917cc..334e50c0be5 100644 --- a/app/services/issuable/clone/attributes_rewriter.rb +++ b/app/services/issuable/clone/attributes_rewriter.rb @@ -18,6 +18,7 @@ module Issuable new_entity.update(update_attributes) copy_resource_label_events + copy_resource_weight_events end private @@ -60,6 +61,20 @@ module Issuable end end + def copy_resource_weight_events + return unless original_entity.respond_to?(:resource_weight_events) + + original_entity.resource_weight_events.find_in_batches do |batch| + events = batch.map do |event| + event.attributes + .except('id', 'reference', 'reference_html') + .merge('issue_id' => new_entity.id) + end + + Gitlab::Database.bulk_insert(ResourceWeightEvent.table_name, events) + end + end + def entity_key new_entity.class.name.parameterize('_').foreign_key end diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 8d1df0d87a7..e8879d4df66 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -2,7 +2,7 @@ module Issues class CreateService < Issues::BaseService - include SpamCheckService + include SpamCheckMethods include ResolveDiscussions def execute diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index b98a4d2567f..68d1657d881 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -2,7 +2,7 @@ module Issues class UpdateService < Issues::BaseService - include SpamCheckService + include SpamCheckMethods def execute(issue) handle_move_between_ids(issue) diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb index fdd2c62a452..b5c27caafa2 100644 --- a/app/services/members/update_service.rb +++ b/app/services/members/update_service.rb @@ -7,9 +7,10 @@ module Members raise Gitlab::Access::AccessDeniedError unless can?(current_user, action_member_permission(permission, member), member) old_access_level = member.human_access + old_expiry = member.expires_at if member.update(params) - after_execute(action: permission, old_access_level: old_access_level, member: member) + after_execute(action: permission, old_access_level: old_access_level, old_expiry: old_expiry, member: member) # Deletes only confidential issues todos for guests enqueue_delete_todos(member) if downgrading_to_guest? diff --git a/app/services/merge_requests/get_urls_service.rb b/app/services/merge_requests/get_urls_service.rb index 7c88c9abb41..de3f2acdf63 100644 --- a/app/services/merge_requests/get_urls_service.rb +++ b/app/services/merge_requests/get_urls_service.rb @@ -9,7 +9,7 @@ module MergeRequests end def execute(changes) - return [] unless project.printing_merge_request_link_enabled + return [] unless project&.printing_merge_request_link_enabled branches = get_branches(changes) merge_requests_map = opened_merge_requests_from_source_branches(branches) diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb index 7e9442c0c7c..bc1e97088af 100644 --- a/app/services/merge_requests/rebase_service.rb +++ b/app/services/merge_requests/rebase_service.rb @@ -8,8 +8,9 @@ module MergeRequests attr_reader :merge_request - def execute(merge_request) + def execute(merge_request, skip_ci: false) @merge_request = merge_request + @skip_ci = skip_ci if rebase success @@ -25,7 +26,7 @@ module MergeRequests return false end - repository.rebase(current_user, merge_request) + repository.rebase(current_user, merge_request, skip_ci: @skip_ci) true rescue => e diff --git a/app/services/metrics/dashboard/clone_dashboard_service.rb b/app/services/metrics/dashboard/clone_dashboard_service.rb new file mode 100644 index 00000000000..b2ec44cb814 --- /dev/null +++ b/app/services/metrics/dashboard/clone_dashboard_service.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +# Copies system dashboard definition in .yml file into designated +# .yml file inside `.gitlab/dashboards` +module Metrics + module Dashboard + class CloneDashboardService < ::BaseService + ALLOWED_FILE_TYPE = '.yml' + USER_DASHBOARDS_DIR = ::Metrics::Dashboard::ProjectDashboardService::DASHBOARD_ROOT + + def self.allowed_dashboard_templates + @allowed_dashboard_templates ||= Set[::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH].freeze + end + + def execute + catch(:error) do + throw(:error, error(_(%q(You can't commit to this project)), :forbidden)) unless push_authorized? + + result = ::Files::CreateService.new(project, current_user, dashboard_attrs).execute + throw(:error, wrap_error(result)) unless result[:status] == :success + + repository.refresh_method_caches([:metrics_dashboard]) + success(result.merge(http_status: :created, dashboard: dashboard_details)) + end + end + + private + + def dashboard_attrs + { + commit_message: params[:commit_message], + file_path: new_dashboard_path, + file_content: new_dashboard_content, + encoding: 'text', + branch_name: branch, + start_branch: repository.branch_exists?(branch) ? branch : project.default_branch + } + end + + def dashboard_details + { + path: new_dashboard_path, + display_name: ::Metrics::Dashboard::ProjectDashboardService.name_for_path(new_dashboard_path), + default: false, + system_dashboard: false + } + end + + def push_authorized? + Gitlab::UserAccess.new(current_user, project: project).can_push_to_branch?(branch) + end + + def dashboard_template + @dashboard_template ||= begin + throw(:error, error(_('Not found.'), :not_found)) unless self.class.allowed_dashboard_templates.include?(params[:dashboard]) + + params[:dashboard] + end + end + + def branch + @branch ||= begin + throw(:error, error(_('There was an error creating the dashboard, branch name is invalid.'), :bad_request)) unless valid_branch_name? + throw(:error, error(_('There was an error creating the dashboard, branch named: %{branch} already exists.') % { branch: params[:branch] }, :bad_request)) unless new_or_default_branch? # temporary validation for first UI iteration + + params[:branch] + end + end + + def new_or_default_branch? + !repository.branch_exists?(params[:branch]) || project.default_branch == params[:branch] + end + + def valid_branch_name? + Gitlab::GitRefValidator.validate(params[:branch]) + end + + def new_dashboard_path + @new_dashboard_path ||= File.join(USER_DASHBOARDS_DIR, file_name) + end + + def file_name + @file_name ||= begin + throw(:error, error(_('The file name should have a .yml extension'), :bad_request)) unless target_file_type_valid? + + File.basename(params[:file_name]) + end + end + + def target_file_type_valid? + File.extname(params[:file_name]) == ALLOWED_FILE_TYPE + end + + def new_dashboard_content + File.read(Rails.root.join(dashboard_template)) + end + + def repository + @repository ||= project.repository + end + + def wrap_error(result) + if result[:message] == 'A file with this name already exists' + error(_("A file with '%{file_name}' already exists in %{branch} branch") % { file_name: file_name, branch: branch }, :bad_request) + else + result + end + end + end + end +end + +Metrics::Dashboard::CloneDashboardService.prepend_if_ee('EE::Metrics::Dashboard::CloneDashboardService') diff --git a/app/services/metrics/sample_metrics_service.rb b/app/services/metrics/sample_metrics_service.rb index 719bc6614e4..9bf32b295e2 100644 --- a/app/services/metrics/sample_metrics_service.rb +++ b/app/services/metrics/sample_metrics_service.rb @@ -4,16 +4,17 @@ module Metrics class SampleMetricsService DIRECTORY = "sample_metrics" - attr_reader :identifier + attr_reader :identifier, :range_minutes - def initialize(identifier) + def initialize(identifier, range_start:, range_end:) @identifier = identifier + @range_minutes = convert_range_minutes(range_start, range_end) end def query return unless identifier && File.exist?(file_location) - YAML.load_file(File.expand_path(file_location, __dir__)) + query_interval end private @@ -22,5 +23,14 @@ module Metrics sanitized_string = identifier.gsub(/[^0-9A-Za-z_]/, '') File.join(Rails.root, DIRECTORY, "#{sanitized_string}.yml") end + + def query_interval + result = YAML.load_file(File.expand_path(file_location, __dir__)) + result[range_minutes] + end + + def convert_range_minutes(range_start, range_end) + ((range_end.to_time - range_start.to_time) / 1.minute).to_i + end end end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index accfdb5b863..50dc98b88e9 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -4,9 +4,7 @@ module Notes class CreateService < ::Notes::BaseService # rubocop:disable Metrics/CyclomaticComplexity def execute - merge_request_diff_head_sha = params.delete(:merge_request_diff_head_sha) - - note = Notes::BuildService.new(project, current_user, params).execute + note = Notes::BuildService.new(project, current_user, params.except(:merge_request_diff_head_sha)).execute # n+1: https://gitlab.com/gitlab-org/gitlab-foss/issues/37440 note_valid = Gitlab::GitalyClient.allow_n_plus_1_calls do @@ -23,8 +21,7 @@ module Notes quick_actions_service = QuickActionsService.new(project, current_user) if quick_actions_service.supported?(note) - options = { merge_request_diff_head_sha: merge_request_diff_head_sha } - content, update_params, message = quick_actions_service.execute(note, options) + content, update_params, message = quick_actions_service.execute(note, quick_action_options) only_commands = content.empty? @@ -74,6 +71,11 @@ module Notes private + # EE::Notes::CreateService would override this method + def quick_action_options + { merge_request_diff_head_sha: params[:merge_request_diff_head_sha] } + end + def tracking_data_for(note) label = Gitlab.ee? && note.author == User.visual_review_bot ? 'anonymous_visual_review_note' : 'note' @@ -84,3 +86,5 @@ module Notes end end end + +Notes::CreateService.prepend_if_ee('EE::Notes::CreateService') diff --git a/app/services/notes/destroy_service.rb b/app/services/notes/destroy_service.rb index fa0c2c5c86b..ee8a680fcb4 100644 --- a/app/services/notes/destroy_service.rb +++ b/app/services/notes/destroy_service.rb @@ -11,3 +11,5 @@ module Notes end end end + +Notes::DestroyService.prepend_if_ee('EE::Notes::DestroyService') diff --git a/app/services/pages_domains/create_acme_order_service.rb b/app/services/pages_domains/create_acme_order_service.rb index c600f497fa5..8eab5c52432 100644 --- a/app/services/pages_domains/create_acme_order_service.rb +++ b/app/services/pages_domains/create_acme_order_service.rb @@ -3,6 +3,9 @@ module PagesDomains class CreateAcmeOrderService attr_reader :pages_domain + # TODO: remove this hack after https://gitlab.com/gitlab-org/gitlab/issues/30146 is implemented + # This makes GitLab automatically retry the certificate obtaining process every 2 hours if process wasn't finished + SHORT_EXPIRATION_DELAY = 2.hours def initialize(pages_domain) @pages_domain = pages_domain @@ -17,7 +20,7 @@ module PagesDomains private_key = OpenSSL::PKey::RSA.new(4096) saved_order = pages_domain.acme_orders.create!( url: order.url, - expires_at: order.expires, + expires_at: [order.expires, SHORT_EXPIRATION_DELAY.from_now].min, private_key: private_key.to_pem, challenge_token: challenge.token, diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb index 0ca89664304..706a6f01a75 100644 --- a/app/services/projects/operations/update_service.rb +++ b/app/services/projects/operations/update_service.rb @@ -30,7 +30,7 @@ module Projects settings = params[:error_tracking_setting_attributes] return {} if settings.blank? - api_url = ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from( + api_url = ::ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from( api_host: settings[:api_host], project_slug: settings.dig(:project, :slug), organization_slug: settings.dig(:project, :organization_slug) diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index e8a87fc4320..8b23f610ad1 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -6,7 +6,6 @@ module Projects FailedToExtractError = Class.new(StandardError) BLOCK_SIZE = 32.kilobytes - MAX_SIZE = 1.terabyte PUBLIC_DIR = 'public' # this has to be invalid group name, @@ -130,12 +129,16 @@ module Projects 1 + max_size / BLOCK_SIZE end + def max_size_from_settings + Gitlab::CurrentSettings.max_pages_size.megabytes + end + def max_size - max_pages_size = Gitlab::CurrentSettings.max_pages_size.megabytes + max_pages_size = max_size_from_settings - return MAX_SIZE if max_pages_size.zero? + return ::Gitlab::Pages::MAX_SIZE if max_pages_size.zero? - [max_pages_size, MAX_SIZE].min + max_pages_size end def tmp_path @@ -200,3 +203,5 @@ module Projects end end end + +Projects::UpdatePagesService.prepend_if_ee('EE::Projects::UpdatePagesService') diff --git a/app/services/prometheus/adapter_service.rb b/app/services/prometheus/adapter_service.rb deleted file mode 100644 index 399f4c35d66..00000000000 --- a/app/services/prometheus/adapter_service.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -module Prometheus - class AdapterService - def initialize(project, deployment_platform = nil) - @project = project - - @deployment_platform = if deployment_platform - deployment_platform - else - project.deployment_platform - end - end - - attr_reader :deployment_platform, :project - - def prometheus_adapter - @prometheus_adapter ||= if service_prometheus_adapter.can_query? - service_prometheus_adapter - else - cluster_prometheus_adapter - end - end - - def service_prometheus_adapter - project.find_or_initialize_service('prometheus') - end - - def cluster_prometheus_adapter - application = deployment_platform&.cluster&.application_prometheus - - application if application&.available? - end - end -end diff --git a/app/services/prometheus/proxy_service.rb b/app/services/prometheus/proxy_service.rb index a62eb76b8ce..3585c90fc8f 100644 --- a/app/services/prometheus/proxy_service.rb +++ b/app/services/prometheus/proxy_service.rb @@ -5,9 +5,17 @@ module Prometheus include ReactiveCaching include Gitlab::Utils::StrongMemoize - self.reactive_cache_key = ->(service) { service.cache_key } + self.reactive_cache_key = ->(service) { [] } self.reactive_cache_lease_timeout = 30.seconds - self.reactive_cache_refresh_interval = 30.seconds + + # reactive_cache_refresh_interval should be set to a value higher than + # reactive_cache_lifetime. If the refresh_interval is less than lifetime + # then the ReactiveCachingWorker will re-query prometheus for this + # PromQL query even though it's (probably) already been picked up by + # the frontend + # refresh_interval should be set less than lifetime only if this data + # is expected to change *and* be fetched again by the frontend + self.reactive_cache_refresh_interval = 90.seconds self.reactive_cache_lifetime = 1.minute self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) } diff --git a/app/services/prometheus/proxy_variable_substitution_service.rb b/app/services/prometheus/proxy_variable_substitution_service.rb index ca56292e9d6..b34afaf80b8 100644 --- a/app/services/prometheus/proxy_variable_substitution_service.rb +++ b/app/services/prometheus/proxy_variable_substitution_service.rb @@ -4,7 +4,10 @@ module Prometheus class ProxyVariableSubstitutionService < BaseService include Stepable - steps :add_params_to_result, :substitute_ruby_variables + steps :validate_variables, + :add_params_to_result, + :substitute_ruby_variables, + :substitute_liquid_variables def initialize(environment, params = {}) @environment, @params = environment, params.deep_dup @@ -16,24 +19,45 @@ module Prometheus private + def validate_variables(_result) + return success unless variables + + unless variables.is_a?(Array) && variables.size.even? + return error(_('Optional parameter "variables" must be an array of keys and values. Ex: [key1, value1, key2, value2]')) + end + + success + end + def add_params_to_result(result) result[:params] = params success(result) end + def substitute_liquid_variables(result) + return success(result) unless query(result) + + result[:params][:query] = + TemplateEngines::LiquidService.new(query(result)).render(full_context) + + success(result) + rescue TemplateEngines::LiquidService::RenderError => e + error(e.message) + end + def substitute_ruby_variables(result) - return success(result) unless query + return success(result) unless query(result) # The % operator doesn't replace variables if the hash contains string # keys. - result[:params][:query] = query % predefined_context.symbolize_keys + result[:params][:query] = query(result) % predefined_context.symbolize_keys success(result) rescue TypeError, ArgumentError => exception log_error(exception.message) - Gitlab::ErrorTracking.track_exception(exception, extra: { - template_string: query, + Gitlab::ErrorTracking.track_exception(exception, { + template_string: query(result), variables: predefined_context }) @@ -44,8 +68,25 @@ module Prometheus @predefined_context ||= Gitlab::Prometheus::QueryVariables.call(@environment) end - def query - params[:query] + def full_context + @full_context ||= predefined_context.reverse_merge(variables_hash) + end + + def variables + params[:variables] + end + + def variables_hash + # .each_slice(2) converts ['key1', 'value1', 'key2', 'value2'] into + # [['key1', 'value1'], ['key2', 'value2']] which is then converted into + # a hash by to_h: {'key1' => 'value1', 'key2' => 'value2'} + # to_h will raise an ArgumentError if the number of elements in the original + # array is not even. + variables&.each_slice(2).to_h + end + + def query(result) + result[:params][:query] end end end diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb index a14e0515a1f..a781eacc40e 100644 --- a/app/services/quick_actions/interpret_service.rb +++ b/app/services/quick_actions/interpret_service.rb @@ -84,7 +84,9 @@ module QuickActions # rubocop: enable CodeReuse/ActiveRecord def find_milestones(project, params = {}) - MilestonesFinder.new(params.merge(project_ids: [project.id], group_ids: [project.group&.id])).execute + group_ids = project.group.self_and_ancestors.select(:id) if project.group + + MilestonesFinder.new(params.merge(project_ids: [project.id], group_ids: group_ids)).execute end def parent diff --git a/app/services/releases/update_service.rb b/app/services/releases/update_service.rb index 6ba8dac21f0..a452f7aa17a 100644 --- a/app/services/releases/update_service.rb +++ b/app/services/releases/update_service.rb @@ -11,10 +11,13 @@ module Releases return error('params is empty', 400) if empty_params? return error("Milestone(s) not found: #{inexistent_milestones.join(', ')}", 400) if inexistent_milestones.any? - params[:milestones] = milestones if param_for_milestone_titles_provided? + if param_for_milestone_titles_provided? + previous_milestones = release.milestones.map(&:title) + params[:milestones] = milestones + end if release.update(params) - success(tag: existing_tag, release: release) + success(tag: existing_tag, release: release, milestones_updated: milestones_updated?(previous_milestones)) else error(release.errors.messages || '400 Bad request', 400) end @@ -29,5 +32,11 @@ module Releases def empty_params? params.except(:tag).empty? end + + def milestones_updated?(previous_milestones) + return false unless param_for_milestone_titles_provided? + + previous_milestones.to_set != release.milestones.map(&:title) + end end end diff --git a/app/services/resource_events/base_synthetic_notes_builder_service.rb b/app/services/resource_events/base_synthetic_notes_builder_service.rb new file mode 100644 index 00000000000..1b85ca811a1 --- /dev/null +++ b/app/services/resource_events/base_synthetic_notes_builder_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# We store events about issuable label changes and weight changes in a separate +# table (not as other system notes), but we still want to display notes about +# label changes and weight changes as classic system notes in UI. This service +# generates "synthetic" notes for label event changes. + +module ResourceEvents + class BaseSyntheticNotesBuilderService + include Gitlab::Utils::StrongMemoize + + attr_reader :resource, :current_user, :params + + def initialize(resource, current_user, params = {}) + @resource = resource + @current_user = current_user + @params = params + end + + def execute + synthetic_notes + end + + private + + def since_fetch_at(events) + return events unless params[:last_fetched_at].present? + + last_fetched_at = Time.at(params.fetch(:last_fetched_at).to_i) + events.created_after(last_fetched_at - NotesFinder::FETCH_OVERLAP) + end + + def resource_parent + strong_memoize(:resource_parent) do + resource.project || resource.group + end + end + end +end diff --git a/app/services/resource_events/merge_into_notes_service.rb b/app/services/resource_events/merge_into_notes_service.rb index 7504773a002..47948fcff6e 100644 --- a/app/services/resource_events/merge_into_notes_service.rb +++ b/app/services/resource_events/merge_into_notes_service.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true -# We store events about issuable label changes in a separate table (not as -# other system notes), but we still want to display notes about label changes -# as classic system notes in UI. This service generates "synthetic" notes for -# label event changes and merges them with classic notes and sorts them by -# creation time. +# We store events about issuable label changes and weight changes in separate tables (not as +# other system notes), but we still want to display notes about label and weight changes +# as classic system notes in UI. This service merges synthetic label and weight notes +# with classic notes and sorts them by creation time. module ResourceEvents class MergeIntoNotesService @@ -19,39 +18,15 @@ module ResourceEvents end def execute(notes = []) - (notes + label_notes).sort_by { |n| n.created_at } + (notes + synthetic_notes).sort_by { |n| n.created_at } end private - def label_notes - label_events_by_discussion_id.map do |discussion_id, events| - LabelNote.from_events(events, resource: resource, resource_parent: resource_parent) - end - end - - # rubocop: disable CodeReuse/ActiveRecord - def label_events_by_discussion_id - return [] unless resource.respond_to?(:resource_label_events) - - events = resource.resource_label_events.includes(:label, user: :status) - events = since_fetch_at(events) - - events.group_by { |event| event.discussion_id } - end - # rubocop: enable CodeReuse/ActiveRecord - - def since_fetch_at(events) - return events unless params[:last_fetched_at].present? - - last_fetched_at = Time.at(params.fetch(:last_fetched_at).to_i) - events.created_after(last_fetched_at - NotesFinder::FETCH_OVERLAP) - end - - def resource_parent - strong_memoize(:resource_parent) do - resource.project || resource.group - end + def synthetic_notes + SyntheticLabelNotesBuilderService.new(resource, current_user, params).execute end end end + +ResourceEvents::MergeIntoNotesService.prepend_if_ee('EE::ResourceEvents::MergeIntoNotesService') diff --git a/app/services/resource_events/synthetic_label_notes_builder_service.rb b/app/services/resource_events/synthetic_label_notes_builder_service.rb new file mode 100644 index 00000000000..fd128101b49 --- /dev/null +++ b/app/services/resource_events/synthetic_label_notes_builder_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# We store events about issuable label changes in a separate table (not as +# other system notes), but we still want to display notes about label changes +# as classic system notes in UI. This service generates "synthetic" notes for +# label event changes. + +module ResourceEvents + class SyntheticLabelNotesBuilderService < BaseSyntheticNotesBuilderService + private + + def synthetic_notes + label_events_by_discussion_id.map do |discussion_id, events| + LabelNote.from_events(events, resource: resource, resource_parent: resource_parent) + end + end + + def label_events_by_discussion_id + return [] unless resource.respond_to?(:resource_label_events) + + events = resource.resource_label_events.includes(:label, user: :status) # rubocop: disable CodeReuse/ActiveRecord + events = since_fetch_at(events) + + events.group_by { |event| event.discussion_id } + end + end +end diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 91c0f9ba104..fe5e823b56c 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -3,6 +3,9 @@ class SearchService include Gitlab::Allowable + SEARCH_TERM_LIMIT = 64 + SEARCH_CHAR_LIMIT = 4096 + def initialize(current_user, params = {}) @current_user = current_user @params = params.dup @@ -42,6 +45,14 @@ class SearchService @show_snippets = params[:snippets] == 'true' end + def valid_query_length? + params[:search].length <= SEARCH_CHAR_LIMIT + end + + def valid_terms_count? + params[:search].split.count { |word| word.length >= 3 } <= SEARCH_TERM_LIMIT + end + delegate :scope, to: :search_service def search_results diff --git a/app/services/snippets/base_service.rb b/app/services/snippets/base_service.rb new file mode 100644 index 00000000000..2b450db0b83 --- /dev/null +++ b/app/services/snippets/base_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Snippets + class BaseService < ::BaseService + private + + def snippet_error_response(snippet, http_status) + ServiceResponse.error( + message: snippet.errors.full_messages.to_sentence, + http_status: http_status, + payload: { snippet: snippet } + ) + end + end +end diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb new file mode 100644 index 00000000000..250e99c466a --- /dev/null +++ b/app/services/snippets/create_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Snippets + class CreateService < Snippets::BaseService + include SpamCheckMethods + + def execute + filter_spam_check_params + + snippet = if project + project.snippets.build(params) + else + PersonalSnippet.new(params) + end + + unless Gitlab::VisibilityLevel.allowed_for?(current_user, snippet.visibility_level) + deny_visibility_level(snippet) + + return snippet_error_response(snippet, 403) + end + + snippet.author = current_user + + spam_check(snippet, current_user) + + snippet_saved = snippet.with_transaction_returning_status do + snippet.save && snippet.store_mentions! + end + + if snippet_saved + UserAgentDetailService.new(snippet, @request).create + Gitlab::UsageDataCounters::SnippetCounter.count(:create) + + ServiceResponse.success(payload: { snippet: snippet } ) + else + snippet_error_response(snippet, 400) + end + end + end +end diff --git a/app/services/snippets/destroy_service.rb b/app/services/snippets/destroy_service.rb new file mode 100644 index 00000000000..f253817d94f --- /dev/null +++ b/app/services/snippets/destroy_service.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Snippets + class DestroyService + include Gitlab::Allowable + + attr_reader :current_user, :project + + def initialize(user, snippet) + @current_user = user + @snippet = snippet + @project = snippet&.project + end + + def execute + if snippet.nil? + return service_response_error('No snippet found.', 404) + end + + unless user_can_delete_snippet? + return service_response_error( + "You don't have access to delete this snippet.", + 403 + ) + end + + if snippet.destroy + ServiceResponse.success(message: 'Snippet was deleted.') + else + service_response_error('Failed to remove snippet.', 400) + end + end + + private + + attr_reader :snippet + + def user_can_delete_snippet? + return can?(current_user, :admin_project_snippet, snippet) if project + + can?(current_user, :admin_personal_snippet, snippet) + end + + def service_response_error(message, http_status) + ServiceResponse.error(message: message, http_status: http_status) + end + end +end diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb new file mode 100644 index 00000000000..8d2c8cac148 --- /dev/null +++ b/app/services/snippets/update_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Snippets + class UpdateService < Snippets::BaseService + include SpamCheckMethods + + def execute(snippet) + # check that user is allowed to set specified visibility_level + new_visibility = visibility_level + + if new_visibility && new_visibility.to_i != snippet.visibility_level + unless Gitlab::VisibilityLevel.allowed_for?(current_user, new_visibility) + deny_visibility_level(snippet, new_visibility) + + return snippet_error_response(snippet, 403) + end + end + + filter_spam_check_params + snippet.assign_attributes(params) + spam_check(snippet, current_user) + + snippet_saved = snippet.with_transaction_returning_status do + snippet.save && snippet.store_mentions! + end + + if snippet_saved + Gitlab::UsageDataCounters::SnippetCounter.count(:update) + + ServiceResponse.success(payload: { snippet: snippet } ) + else + snippet_error_response(snippet, 400) + end + end + end +end diff --git a/app/services/spam/mark_as_spam_service.rb b/app/services/spam/mark_as_spam_service.rb new file mode 100644 index 00000000000..0ebcf17927a --- /dev/null +++ b/app/services/spam/mark_as_spam_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Spam + class MarkAsSpamService + include ::AkismetMethods + + attr_accessor :spammable, :options + + def initialize(spammable:) + @spammable = spammable + @options = {} + + @options[:ip_address] = @spammable.ip_address + @options[:user_agent] = @spammable.user_agent + end + + def execute + return unless spammable.submittable_as_spam? + return unless akismet.submit_spam + + spammable.user_agent_detail.update_attribute(:submitted, true) + end + end +end diff --git a/app/services/spam_service.rb b/app/services/spam_service.rb index babe69cfdc8..ba9b812a01c 100644 --- a/app/services/spam_service.rb +++ b/app/services/spam_service.rb @@ -1,10 +1,12 @@ # frozen_string_literal: true class SpamService + include AkismetMethods + attr_accessor :spammable, :request, :options attr_reader :spam_log - def initialize(spammable, request = nil) + def initialize(spammable:, request:) @spammable = spammable @request = request @options = {} @@ -19,16 +21,6 @@ class SpamService end end - def mark_as_spam! - return false unless spammable.submittable_as_spam? - - if akismet.submit_spam - spammable.user_agent_detail.update_attribute(:submitted, true) - else - false - end - end - def when_recaptcha_verified(recaptcha_verified, api = false) # In case it's a request which is already verified through recaptcha, yield # block. @@ -54,27 +46,6 @@ class SpamService true end - def akismet - @akismet ||= AkismetService.new( - spammable_owner, - spammable.spammable_text, - options - ) - end - - def spammable_owner - @user ||= User.find(spammable_owner_id) - end - - def spammable_owner_id - @owner_id ||= - if spammable.respond_to?(:author_id) - spammable.author_id - elsif spammable.respond_to?(:creator_id) - spammable.creator_id - end - end - def check_for_spam? spammable.check_for_spam? end diff --git a/app/services/suggestions/apply_service.rb b/app/services/suggestions/apply_service.rb index 8ba50e22b09..a6485e42bdb 100644 --- a/app/services/suggestions/apply_service.rb +++ b/app/services/suggestions/apply_service.rb @@ -2,6 +2,24 @@ module Suggestions class ApplyService < ::BaseService + DEFAULT_SUGGESTION_COMMIT_MESSAGE = 'Apply suggestion to %{file_path}' + + PLACEHOLDERS = { + 'project_path' => ->(suggestion, user) { suggestion.project.path }, + 'project_name' => ->(suggestion, user) { suggestion.project.name }, + 'file_path' => ->(suggestion, user) { suggestion.file_path }, + 'branch_name' => ->(suggestion, user) { suggestion.branch }, + 'username' => ->(suggestion, user) { user.username }, + 'user_full_name' => ->(suggestion, user) { user.name } + }.freeze + + # This regex is built dynamically using the keys from the PLACEHOLDER struct. + # So, we can easily add new placeholder just by modifying the PLACEHOLDER hash. + # This regex will build the new PLACEHOLDER_REGEX with the new information + PLACEHOLDERS_REGEX = Regexp.union(PLACEHOLDERS.keys.map { |key| Regexp.new(Regexp.escape(key)) }).freeze + + attr_reader :current_user + def initialize(current_user) @current_user = current_user end @@ -22,7 +40,7 @@ module Suggestions end params = file_update_params(suggestion, diff_file) - result = ::Files::UpdateService.new(suggestion.project, @current_user, params).execute + result = ::Files::UpdateService.new(suggestion.project, current_user, params).execute if result[:status] == :success suggestion.update(commit_id: result[:result], applied: true) @@ -46,13 +64,14 @@ module Suggestions def file_update_params(suggestion, diff_file) blob = diff_file.new_blob + project = suggestion.project file_path = suggestion.file_path branch_name = suggestion.branch file_content = new_file_content(suggestion, blob) - commit_message = "Apply suggestion to #{file_path}" + commit_message = processed_suggestion_commit_message(suggestion) file_last_commit = - Gitlab::Git::Commit.last_for_path(suggestion.project.repository, + Gitlab::Git::Commit.last_for_path(project.repository, blob.commit_id, blob.path) @@ -75,5 +94,17 @@ module Suggestions content.join end + + def suggestion_commit_message(project) + project.suggestion_commit_message || DEFAULT_SUGGESTION_COMMIT_MESSAGE + end + + def processed_suggestion_commit_message(suggestion) + message = suggestion_commit_message(suggestion.project) + + Gitlab::StringPlaceholderReplacer.replace_string_placeholders(message, PLACEHOLDERS_REGEX) do |key| + PLACEHOLDERS[key].call(suggestion, current_user) + end + end end end diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb index 06d2037fb63..0d369c23b57 100644 --- a/app/services/system_hooks_service.rb +++ b/app/services/system_hooks_service.rb @@ -14,7 +14,7 @@ class SystemHooksService hook.async_execute(data, 'system_hooks') end - Gitlab::Plugin.execute_all_async(data) + Gitlab::FileHook.execute_all_async(data) end private diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 25e3282d3fb..38e0a7d34ad 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -60,9 +60,7 @@ module SystemNoteService # # Returns the created Note object def change_due_date(noteable, project, author, due_date) - body = due_date ? "changed due date to #{due_date.to_s(:long)}" : 'removed due date' - - create_note(NoteSummary.new(noteable, project, author, body, action: 'due_date')) + ::SystemNotes::TimeTrackingService.new(noteable: noteable, project: project, author: author).change_due_date(due_date) end # Called when the estimated time of a Noteable is changed @@ -80,14 +78,7 @@ module SystemNoteService # # Returns the created Note object def change_time_estimate(noteable, project, author) - parsed_time = Gitlab::TimeTrackingFormatter.output(noteable.time_estimate) - body = if noteable.time_estimate == 0 - "removed time estimate" - else - "changed time estimate to #{parsed_time}" - end - - create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking')) + ::SystemNotes::TimeTrackingService.new(noteable: noteable, project: project, author: author).change_time_estimate end # Called when the spent time of a Noteable is changed @@ -105,21 +96,7 @@ module SystemNoteService # # Returns the created Note object def change_time_spent(noteable, project, author) - time_spent = noteable.time_spent - - if time_spent == :reset - body = "removed time spent" - else - spent_at = noteable.spent_at - parsed_time = Gitlab::TimeTrackingFormatter.output(time_spent.abs) - action = time_spent > 0 ? 'added' : 'subtracted' - - text_parts = ["#{action} #{parsed_time} of time spent"] - text_parts << "at #{spent_at}" if spent_at - body = text_parts.join(' ') - end - - create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking')) + ::SystemNotes::TimeTrackingService.new(noteable: noteable, project: project, author: author).change_time_spent end def change_status(noteable, project, author, status, source = nil) diff --git a/app/services/system_notes/time_tracking_service.rb b/app/services/system_notes/time_tracking_service.rb new file mode 100644 index 00000000000..8de42bd3225 --- /dev/null +++ b/app/services/system_notes/time_tracking_service.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module SystemNotes + class TimeTrackingService < ::SystemNotes::BaseService + # Called when the due_date of a Noteable is changed + # + # due_date - Due date being assigned, or nil + # + # Example Note text: + # + # "removed due date" + # + # "changed due date to September 20, 2018" + # + # Returns the created Note object + def change_due_date(due_date) + body = due_date ? "changed due date to #{due_date.to_s(:long)}" : 'removed due date' + + create_note(NoteSummary.new(noteable, project, author, body, action: 'due_date')) + end + + # Called when the estimated time of a Noteable is changed + # + # time_estimate - Estimated time + # + # Example Note text: + # + # "removed time estimate" + # + # "changed time estimate to 3d 5h" + # + # Returns the created Note object + def change_time_estimate + parsed_time = Gitlab::TimeTrackingFormatter.output(noteable.time_estimate) + body = if noteable.time_estimate == 0 + "removed time estimate" + else + "changed time estimate to #{parsed_time}" + end + + create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking')) + end + + # Called when the spent time of a Noteable is changed + # + # time_spent - Spent time + # + # Example Note text: + # + # "removed time spent" + # + # "added 2h 30m of time spent" + # + # Returns the created Note object + def change_time_spent + time_spent = noteable.time_spent + + if time_spent == :reset + body = "removed time spent" + else + spent_at = noteable.spent_at + parsed_time = Gitlab::TimeTrackingFormatter.output(time_spent.abs) + action = time_spent > 0 ? 'added' : 'subtracted' + + text_parts = ["#{action} #{parsed_time} of time spent"] + text_parts << "at #{spent_at}" if spent_at + body = text_parts.join(' ') + end + + create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking')) + end + end +end diff --git a/app/services/template_engines/liquid_service.rb b/app/services/template_engines/liquid_service.rb new file mode 100644 index 00000000000..809ebd0316b --- /dev/null +++ b/app/services/template_engines/liquid_service.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module TemplateEngines + class LiquidService < BaseService + RenderError = Class.new(StandardError) + + DEFAULT_RENDER_SCORE_LIMIT = 1_000 + + def initialize(string) + @template = Liquid::Template.parse(string) + end + + def render(context, render_score_limit: DEFAULT_RENDER_SCORE_LIMIT) + set_limits(render_score_limit) + + @template.render!(context.stringify_keys) + rescue Liquid::MemoryError => e + handle_exception(e, string: @string, context: context) + + raise RenderError, _('Memory limit exceeded while rendering template') + rescue Liquid::Error => e + handle_exception(e, string: @string, context: context) + + raise RenderError, _('Error rendering query') + end + + private + + def set_limits(render_score_limit) + @template.resource_limits.render_score_limit = render_score_limit + + # We can also set assign_score_limit and render_length_limit if required. + + # render_score_limit limits the number of nodes (string, variable, block, tags) + # that are allowed in the template. + # render_length_limit seems to limit the sum of the bytesize of all node blocks. + # assign_score_limit seems to limit the sum of the bytesize of all capture blocks. + end + + def handle_exception(exception, extra = {}) + log_error(exception.message) + Gitlab::ErrorTracking.track_exception(exception, { + template_string: extra[:string], + variables: extra[:context] + }) + end + end +end diff --git a/app/services/update_snippet_service.rb b/app/services/update_snippet_service.rb deleted file mode 100644 index ac7f8e9b1f5..00000000000 --- a/app/services/update_snippet_service.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -class UpdateSnippetService < BaseService - include SpamCheckService - - attr_accessor :snippet - - def initialize(project, user, snippet, params) - super(project, user, params) - @snippet = snippet - end - - def execute - # check that user is allowed to set specified visibility_level - new_visibility = visibility_level - - if new_visibility && new_visibility.to_i != snippet.visibility_level - unless Gitlab::VisibilityLevel.allowed_for?(current_user, new_visibility) - deny_visibility_level(snippet, new_visibility) - return snippet - end - end - - filter_spam_check_params - snippet.assign_attributes(params) - spam_check(snippet, current_user) - - snippet_saved = snippet.with_transaction_returning_status do - snippet.save && snippet.store_mentions! - end - - if snippet_saved - Gitlab::UsageDataCounters::SnippetCounter.count(:update) - end - end -end diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb index 33444c2a7dc..85855f45e33 100644 --- a/app/services/users/activity_service.rb +++ b/app/services/users/activity_service.rb @@ -4,7 +4,7 @@ module Users class ActivityService LEASE_TIMEOUT = 1.minute.to_i - def initialize(author, activity) + def initialize(author) @user = if author.respond_to?(:username) author elsif author.respond_to?(:user) @@ -12,7 +12,6 @@ module Users end @user = nil unless @user.is_a?(User) - @activity = activity end def execute diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb index ea4d11e728e..d18f20bc1db 100644 --- a/app/services/users/build_service.rb +++ b/app/services/users/build_service.rb @@ -86,6 +86,8 @@ module Users :email_confirmation, :password_automatically_set, :name, + :first_name, + :last_name, :password, :username ] @@ -107,6 +109,12 @@ module Users if user_params[:skip_confirmation].nil? user_params[:skip_confirmation] = skip_user_confirmation_email_from_setting end + + fallback_name = "#{user_params[:first_name]} #{user_params[:last_name]}" + + if user_params[:name].blank? && fallback_name.present? + user_params = user_params.merge(name: fallback_name) + end end if user_default_internal_regex_enabled? && !user_params.key?(:external) diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb index e341c7f0537..643ebdc6839 100644 --- a/app/services/users/destroy_service.rb +++ b/app/services/users/destroy_service.rb @@ -56,6 +56,13 @@ module Users MigrateToGhostUserService.new(user).execute unless options[:hard_delete] + if Feature.enabled?(:destroy_user_associations_in_batches) + # Rails attempts to load all related records into memory before + # destroying: https://github.com/rails/rails/issues/22510 + # This ensures we delete records in batches. + user.destroy_dependent_associations_in_batches + end + # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing user_data = user.destroy namespace.destroy diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb index 422c8ed6575..e7667b0ca18 100644 --- a/app/services/users/update_service.rb +++ b/app/services/users/update_service.rb @@ -17,6 +17,8 @@ module Users yield(@user) if block_given? user_exists = @user.persisted? + + discard_read_only_attributes assign_attributes assign_identity @@ -50,13 +52,19 @@ module Users success end - def assign_attributes + def discard_read_only_attributes + discard_synced_attributes + end + + def discard_synced_attributes if (metadata = @user.user_synced_attributes_metadata) read_only = metadata.read_only_attributes params.reject! { |key, _| read_only.include?(key.to_sym) } end + end + def assign_attributes @user.assign_attributes(params.except(*identity_attributes)) unless params.empty? end diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb index d42c9dbedf4..b79a5deb9c0 100644 --- a/app/uploaders/avatar_uploader.rb +++ b/app/uploaders/avatar_uploader.rb @@ -5,6 +5,9 @@ class AvatarUploader < GitlabUploader include RecordsUploads::Concern include ObjectStorage::Concern prepend ObjectStorage::Extension::RecordsUploads + include UploadTypeCheck::Concern + + check_upload_type extensions: AvatarUploader::SAFE_IMAGE_EXT def exists? model.avatar.file && model.avatar.file.present? diff --git a/app/uploaders/favicon_uploader.rb b/app/uploaders/favicon_uploader.rb index a0b275b56a9..f393fdf0d84 100644 --- a/app/uploaders/favicon_uploader.rb +++ b/app/uploaders/favicon_uploader.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true class FaviconUploader < AttachmentUploader + include UploadTypeCheck::Concern + EXTENSION_WHITELIST = %w[png ico].freeze + check_upload_type extensions: EXTENSION_WHITELIST + def extension_whitelist EXTENSION_WHITELIST end diff --git a/app/uploaders/upload_type_check.rb b/app/uploaders/upload_type_check.rb new file mode 100644 index 00000000000..2837b001660 --- /dev/null +++ b/app/uploaders/upload_type_check.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +# Ensure that uploaded files are what they say they are for security and +# handling purposes. The checks are not 100% reliable so we err on the side of +# caution and allow by default, and deny when we're confident of a fail state. +# +# Include this concern, then call `check_upload_type` to check all +# uploads. Attach a `mime_type` or `extensions` parameter to only check +# specific upload types. Both parameters will be normalized to a MIME type and +# checked against the inferred MIME type of the upload content and filename +# extension. +# +# class YourUploader +# include UploadTypeCheck::Concern +# check_upload_type mime_types: ['image/png', /image\/jpe?g/] +# +# # or... +# +# check_upload_type extensions: ['png', 'jpg', 'jpeg'] +# end +# +# The mime_types parameter can accept `NilClass`, `String`, `Regexp`, +# `Array[String, Regexp]`. This matches the CarrierWave `extension_whitelist` +# and `content_type_whitelist` family of behavior. +# +# The extensions parameter can accept `NilClass`, `String`, `Array[String]`. +module UploadTypeCheck + module Concern + extend ActiveSupport::Concern + + class_methods do + def check_upload_type(mime_types: nil, extensions: nil) + define_method :check_upload_type_callback do |file| + magic_file = MagicFile.new(file.to_file) + + # Map file extensions back to mime types. + if extensions + mime_types = Array(mime_types) + + Array(extensions).map { |e| MimeMagic::EXTENSIONS[e] } + end + + if mime_types.nil? || magic_file.matches_mime_types?(mime_types) + check_content_matches_extension!(magic_file) + end + end + before :cache, :check_upload_type_callback + end + end + + def check_content_matches_extension!(magic_file) + return if magic_file.ambiguous_type? + + if magic_file.magic_type != magic_file.ext_type + raise CarrierWave::IntegrityError, 'Content type does not match file extension' + end + end + end + + # Convenience class to wrap MagicMime objects. + class MagicFile + attr_reader :file + + def initialize(file) + @file = file + end + + def magic_type + @magic_type ||= MimeMagic.by_magic(file) + end + + def ext_type + @ext_type ||= MimeMagic.by_path(file.path) + end + + def magic_type_type + magic_type&.type + end + + def ext_type_type + ext_type&.type + end + + def matches_mime_types?(mime_types) + Array(mime_types).any? do |mt| + magic_type_type =~ /\A#{mt}\z/ || ext_type_type =~ /\A#{mt}\z/ + end + end + + # - Both types unknown or text/plain. + # - Ambiguous magic type with text extension. Plain text file. + # - Text magic type with ambiguous extension. TeX file missing extension. + def ambiguous_type? + (ext_type.to_s.blank? && magic_type.to_s.blank?) || + (magic_type.to_s.blank? && ext_type_type == 'text/plain') || + (ext_type.to_s.blank? && magic_type_type == 'text/plain') + end + end +end diff --git a/app/validators/key_restriction_validator.rb b/app/validators/key_restriction_validator.rb index 891d13b1596..9809047ae83 100644 --- a/app/validators/key_restriction_validator.rb +++ b/app/validators/key_restriction_validator.rb @@ -21,7 +21,8 @@ class KeyRestrictionValidator < ActiveModel::EachValidator def supported_sizes_message sizes = self.class.supported_sizes(options[:type]) - sizes.to_sentence(last_word_connector: ', or ', two_words_connector: ' or ') + + Gitlab::Utils.to_exclusive_sentence(sizes) end def valid_restriction?(value) diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml index 6b95c0f40c5..80a53dba2aa 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -52,6 +52,7 @@ = f.label :user_show_add_ssh_key_message, class: 'form-check-label' do = _("Inform users without uploaded SSH keys that they can't push over SSH until one is added") + = render_if_exists 'admin/application_settings/updating_name_disabled_for_users', form: f = render_if_exists 'admin/application_settings/availability_on_namespace_setting', form: f = f.submit _('Save changes'), class: 'btn btn-success qa-save-changes-button' diff --git a/app/views/admin/application_settings/_gitaly.html.haml b/app/views/admin/application_settings/_gitaly.html.haml index 1da02de0461..fac2de8811f 100644 --- a/app/views/admin/application_settings/_gitaly.html.haml +++ b/app/views/admin/application_settings/_gitaly.html.haml @@ -8,6 +8,9 @@ .form-text.text-muted Timeout for Gitaly calls from the GitLab application (in seconds). This timeout is not enforced for git fetch/push operations or Sidekiq jobs. + This timeout should be less than the worker timeout. If a Gitaly call timeout would exceed the + worker timeout, the remaining time from the worker timeout would be used to avoid having to terminate + the worker. .form-group = f.label :gitaly_timeout_fast, 'Fast Timeout Period', class: 'label-bold' = f.number_field :gitaly_timeout_fast, class: 'form-control' diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml index b15afb3b806..8214cf8ce9f 100644 --- a/app/views/admin/application_settings/_pages.html.haml +++ b/app/views/admin/application_settings/_pages.html.haml @@ -15,6 +15,15 @@ .form-text.text-muted = _("Domain verification is an essential security measure for public GitLab sites. Users are required to demonstrate they control a domain before it is enabled") = link_to icon('question-circle'), help_page_path('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: '4-verify-the-domains-ownership') + - if Gitlab.config.pages.access_control + .form-group + .form-check + = f.check_box :force_pages_access_control, class: 'form-check-input' + = f.label :force_pages_access_control, class: 'form-check-label' do + = _("Disable public access to Pages sites") + .form-text.text-muted + = _("Access to Pages websites are controlled based on the user's membership to a given project. By checking this box, users will be required to be logged in to have access to all Pages websites in your instance.") + = link_to icon('question-circle'), help_page_path('administration/pages/index.md', anchor: 'disabling-public-access-to-all-pages-websites') %h5 = _("Configure Let's Encrypt") %p diff --git a/app/views/admin/application_settings/_signup.html.haml b/app/views/admin/application_settings/_signup.html.haml index b9d9d86ca30..c29e52abaf6 100644 --- a/app/views/admin/application_settings/_signup.html.haml +++ b/app/views/admin/application_settings/_signup.html.haml @@ -7,6 +7,8 @@ = f.check_box :signup_enabled, class: 'form-check-input' = f.label :signup_enabled, class: 'form-check-label' do Sign-up enabled + .form-text.text-muted + = _("When enabled, any user visiting %{host} will be able to create an account.") % { host: "#{new_user_session_url(host: Gitlab.config.gitlab.host)}" } .form-group .form-check = f.check_box :send_user_confirmation_email, class: 'form-check-input' 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 55a48da8342..ff40d7da892 100644 --- a/app/views/admin/application_settings/metrics_and_profiling.html.haml +++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml @@ -47,6 +47,9 @@ .settings-content = render 'performance_bar' +- if Feature.enabled?(:self_monitoring_project) + .js-self-monitoring-settings{ data: self_monitoring_project_data } + %section.settings.as-usage.no-animate#js-usage-settings{ class: ('expanded' if expanded_by_default?) } .settings-header#usage-statistics %h4 diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml index 44d57beec0f..33b56655206 100644 --- a/app/views/admin/broadcast_messages/_form.html.haml +++ b/app/views/admin/broadcast_messages/_form.html.haml @@ -1,23 +1,38 @@ -.broadcast-message-preview{ style: broadcast_message_style(@broadcast_message) } - = sprite_icon('bullhorn', size: 16, css_class:'vertical-align-text-top mr-2') +.broadcast-banner-message.js-broadcast-banner-message-preview.mt-2{ style: broadcast_message_style(@broadcast_message), class: ('hidden' unless @broadcast_message.banner? ) } + = sprite_icon('bullhorn', size: 16, css_class:'vertical-align-text-top') .js-broadcast-message-preview - if @broadcast_message.message.present? = render_broadcast_message(@broadcast_message) - else Your message here +- if Feature.enabled?(:broadcast_notification_type) + .d-flex.justify-content-center + .broadcast-notification-message.preview.js-broadcast-notification-message-preview.mt-2{ class: ('hidden' unless @broadcast_message.notification? ) } + = sprite_icon('bullhorn', size: 16, css_class:'vertical-align-text-top') + .js-broadcast-message-preview + - if @broadcast_message.message.present? + = render_broadcast_message(@broadcast_message) + - else + Your message here = form_for [:admin, @broadcast_message], html: { class: 'broadcast-message-form js-quick-submit js-requires-input'} do |f| = form_errors(@broadcast_message) - .form-group.row + .form-group.row.mt-4 .col-sm-2.col-form-label = f.label :message .col-sm-10 - = f.text_area :message, class: "form-control js-autosize", + = f.text_area :message, class: "form-control js-autosize js-broadcast-message-message", required: true, dir: 'auto', data: { preview_path: preview_admin_broadcast_messages_path } - .form-group.row + - if Feature.enabled?(:broadcast_notification_type) + .form-group.row + .col-sm-2.col-form-label + = f.label :broadcast_type, _('Type') + .col-sm-10 + = f.select :broadcast_type, broadcast_type_options, {}, class: 'form-control js-broadcast-message-type' + .form-group.row.js-broadcast-message-background-color-form-group{ class: ('hidden' unless @broadcast_message.banner? ) } .col-sm-2.col-form-label = f.label :color, _("Background color") .col-sm-10 @@ -25,7 +40,7 @@ .input-group-prepend .input-group-text.label-color-preview{ :style => 'background-color: ' + @broadcast_message.color + '; color: ' + @broadcast_message.font } = ' '.html_safe - = f.text_field :color, class: "form-control" + = f.text_field :color, class: "form-control js-broadcast-message-color" .form-text.text-muted = _('Choose any color.') %br diff --git a/app/views/admin/broadcast_messages/index.html.haml b/app/views/admin/broadcast_messages/index.html.haml index 4731421fd9e..6f2433e3306 100644 --- a/app/views/admin/broadcast_messages/index.html.haml +++ b/app/views/admin/broadcast_messages/index.html.haml @@ -20,6 +20,7 @@ %th Starts %th Ends %th Target Path + %th Type %th %tbody - @broadcast_messages.each do |message| @@ -27,7 +28,7 @@ %td = broadcast_message_status(message) %td - = broadcast_message(message) + = broadcast_message(message, preview: true) %td = message.starts_at %td @@ -35,6 +36,8 @@ %td = message.target_path %td + = message.broadcast_type.capitalize + %td = link_to sprite_icon('pencil-square'), edit_admin_broadcast_message_path(message), title: 'Edit', class: 'btn' = link_to sprite_icon('remove'), admin_broadcast_message_path(message), method: :delete, remote: true, title: 'Remove', class: 'js-remove-tr btn btn-danger' diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml index eed3ec74d60..1c14291b58e 100644 --- a/app/views/admin/hooks/index.html.haml +++ b/app/views/admin/hooks/index.html.haml @@ -11,4 +11,4 @@ = render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class -= render 'shared/plugins/index' += render 'shared/file_hooks/index' diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index f8ef7a45f7f..818d265c767 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -57,24 +57,22 @@ %li.input-token %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' } } - = button_tag class: %w[btn btn-link] do - = sprite_icon('search') - %span - = _('Press Enter or click to search') %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item + %li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } } = button_tag class: %w[btn btn-link] do -# Encapsulate static class name `{{icon}}` inside #{} to bypass -# haml lint's ClassAttributeWithStaticValue %svg %use{ 'xlink:href': "#{'{{icon}}'}" } %span.js-filter-hint - {{hint}} - %span.js-filter-tag.dropdown-light-content - {{tag}} - + {{formattedKey}} + #js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu + %ul.filter-dropdown{ data: { dropdown: true, dynamic: true } } + %li.filter-dropdown-item{ data: { value: "{{ title }}" } } + %button.btn.btn-link{ type: 'button' } + {{ title }} + %span.btn-helptext + {{ help }} #js-dropdown-admin-runner-status.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } - Ci::Runner::AVAILABLE_STATUSES.each do |status| diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml index ca5109614fc..978e830d0e4 100644 --- a/app/views/admin/users/_user.html.haml +++ b/app/views/admin/users/_user.html.haml @@ -19,8 +19,8 @@ = link_to _('Edit'), edit_admin_user_path(user), id: "edit_#{dom_id(user)}", class: 'btn btn-default' - unless user == current_user %button.dropdown-new.btn.btn-default{ type: 'button', data: { toggle: 'dropdown' } } - = icon('cog') - = icon('caret-down') + = sprite_icon('settings') + = sprite_icon('chevron-down') %ul.dropdown-menu.dropdown-menu-right %li.dropdown-header = _('Settings') diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index 3c6ad899d1e..ecbabab3e7f 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -9,7 +9,7 @@ = nav_link(html_options: { class: active_when(params[:filter].nil?) }) do = link_to admin_users_path do = s_('AdminUsers|Active') - %small.badge.badge-pill= limited_counter_with_delimiter(User.active) + %small.badge.badge-pill= limited_counter_with_delimiter(User.active_without_ghosts) = nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do = link_to admin_users_path(filter: "admins") do = s_('AdminUsers|Admins') diff --git a/app/views/clusters/clusters/_cluster.html.haml b/app/views/clusters/clusters/_cluster.html.haml index b89789e9915..04afc38a056 100644 --- a/app/views/clusters/clusters/_cluster.html.haml +++ b/app/views/clusters/clusters/_cluster.html.haml @@ -3,7 +3,7 @@ .table-section.section-60 .table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Kubernetes cluster") .table-mobile-content - = cluster.item_link(clusterable) + = cluster.item_link(clusterable, html_options: { data: { qa_selector: 'cluster', qa_cluster_name: cluster.name } }) - unless cluster.enabled? %span.badge.badge-danger Connection disabled .table-section.section-25 diff --git a/app/views/clusters/clusters/aws/_new.html.haml b/app/views/clusters/clusters/aws/_new.html.haml index d89e6965dac..5bbdadf83f3 100644 --- a/app/views/clusters/clusters/aws/_new.html.haml +++ b/app/views/clusters/clusters/aws/_new.html.haml @@ -11,6 +11,6 @@ 'role-arn' => @aws_role.role_arn, 'instance-types' => @instance_types, 'kubernetes-integration-help-path' => help_page_path('user/project/clusters/index'), - 'account-and-external-ids-help-path' => help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'eks-cluster'), - 'create-role-arn-help-path' => help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'eks-cluster'), + 'account-and-external-ids-help-path' => help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'new-eks-cluster'), + 'create-role-arn-help-path' => help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'new-eks-cluster'), 'external-link-icon' => icon('external-link') } } diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index 5beeaf7259a..4b295cd022d 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -30,6 +30,7 @@ help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications'), ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-endpoint'), ingress_dns_help_path: help_page_path('user/project/clusters/index.md', anchor: 'manually-determining-the-external-endpoint'), + ingress_mod_security_help_path: help_page_path('user/clusters/applications.md', anchor: 'web-application-firewall-modsecurity'), environments_help_path: help_page_path('ci/environments', anchor: 'defining-environments'), clusters_help_path: help_page_path('user/project/clusters/index.md', anchor: 'deploying-to-a-kubernetes-cluster'), deploy_boards_help_path: help_page_path('user/project/deploy_boards.html', anchor: 'enabling-deploy-boards'), diff --git a/app/views/dashboard/projects/_projects.html.haml b/app/views/dashboard/projects/_projects.html.haml index 9e0e908e656..5122164dbcb 100644 --- a/app/views/dashboard/projects/_projects.html.haml +++ b/app/views/dashboard/projects/_projects.html.haml @@ -1 +1 @@ -= render 'shared/projects/list', projects: @projects, ci: true, user: current_user += render 'shared/projects/list', projects: @projects, pipeline_status: Feature.enabled?(:dashboard_pipeline_status, default_enabled: true), user: current_user diff --git a/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml b/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml index 5d163d03c73..4832861445b 100644 --- a/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml +++ b/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml @@ -1,14 +1,23 @@ - content_for(:page_title, _('Register for GitLab')) +- max_first_name_length = max_last_name_length = 127 - max_username_length = 255 .signup-box.p-3.mb-2 .signup-body = form_for(resource, as: "new_#{resource_name}", url: registration_path(resource_name), html: { class: "new_new_user gl-show-field-errors", "aria-live" => "assertive" }) do |f| .devise-errors.mt-0 = render "devise/shared/error_messages", resource: resource - = invisible_captcha + - if Feature.enabled?(:invisible_captcha) + = invisible_captcha + .name.form-row + .col.form-group + = f.label :first_name, _('First name'), for: 'new_user_first_name', class: 'label-bold' + = f.text_field :first_name, class: 'form-control top js-block-emoji js-validate-length', :data => { :max_length => max_first_name_length, :max_length_message => _("First Name is too long (maximum is %{max_length} characters).") % { max_length: max_first_name_length }, :qa_selector => 'new_user_firstname_field' }, required: true, title: _("This field is required.") + .col.form-group + = f.label :last_name, _('Last name'), for: 'new_user_last_name', class: 'label-bold' + = f.text_field :last_name, class: "form-control top js-block-emoji js-validate-length", :data => { :max_length => max_last_name_length, :max_length_message => _("Last Name is too long (maximum is %{max_length} characters).") % { max_length: max_last_name_length }, :qa_selector => 'new_user_lastname_field' }, required: true, title: _("This field is required.") .username.form-group = f.label :username, class: 'label-bold' - = f.text_field :username, class: "form-control middle js-block-emoji js-validate-length js-validate-username", :data => { :max_length => max_username_length, :max_length_message => s_("SignUp|Username is too long (maximum is %{max_length} characters).") % { max_length: max_username_length }, :qa_selector => 'new_user_username_field' }, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.") + = f.text_field :username, class: "form-control middle js-block-emoji js-validate-length js-validate-username", :data => { :max_length => max_username_length, :max_length_message => _("Username is too long (maximum is %{max_length} characters).") % { max_length: max_username_length }, :qa_selector => 'new_user_username_field' }, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.") %p.validation-error.gl-field-error-ignore.field-validation.mt-1.hide.cred= _('Username is already taken.') %p.validation-success.gl-field-error-ignore.field-validation.mt-1.hide.cgreen= _('Username is available.') %p.validation-pending.gl-field-error-ignore.field-validation.mt-1.hide= _('Checking username availability...') @@ -27,5 +36,8 @@ - accept_terms_label = _("I accept the %{terms_link}") % { terms_link: terms_link } = accept_terms_label.html_safe = render_if_exists 'devise/shared/email_opted_in', f: f + %div + - if show_recaptcha_sign_up? + = recaptcha_tags .submit-container.mt-3 = f.submit _("Register"), class: "btn-register btn btn-block btn-success mb-0 p-2", data: { qa_selector: 'new_user_register_button' } diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index 2cd77af6877..7c5b85c903c 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -1,4 +1,4 @@ -- max_name_length = 128 +- max_name_length = 255 - max_username_length = 255 #register-pane.tab-pane.login-box{ role: 'tabpanel' } .login-body diff --git a/app/views/errors/_footer.html.haml b/app/views/errors/_footer.html.haml index e67a3a142f6..bb9edc54b4b 100644 --- a/app/views/errors/_footer.html.haml +++ b/app/views/errors/_footer.html.haml @@ -4,7 +4,7 @@ = link_to s_('Nav|Home'), root_path %li - if current_user - = link_to s_('Nav|Sign out and sign in with a different account'), destroy_user_session_path + = link_to s_('Nav|Sign out and sign in with a different account'), destroy_user_session_path, method: :post - else = link_to s_('Nav|Sign In / Register'), new_session_path(:user, redirect_to_referer: 'yes') %li diff --git a/app/views/explore/projects/_projects.html.haml b/app/views/explore/projects/_projects.html.haml index 35b32662b8a..d819c4ea554 100644 --- a/app/views/explore/projects/_projects.html.haml +++ b/app/views/explore/projects/_projects.html.haml @@ -1,2 +1,2 @@ - is_explore_page = defined?(explore_page) && explore_page -= render 'shared/projects/list', projects: projects, user: current_user, explore_page: is_explore_page += render 'shared/projects/list', projects: projects, user: current_user, explore_page: is_explore_page, pipeline_status: Feature.enabled?(:dashboard_pipeline_status, default_enabled: true) diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 0e78ce9f656..fe5a00e3be9 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -53,4 +53,6 @@ .settings-content = render 'groups/settings/advanced' += render_if_exists 'shared/groups/max_pages_size_setting' + = render 'shared/confirm_modal', phrase: @group.path diff --git a/app/views/groups/group_members/_new_group_member.html.haml b/app/views/groups/group_members/_new_group_member.html.haml deleted file mode 100644 index 93dd8f48a60..00000000000 --- a/app/views/groups/group_members/_new_group_member.html.haml +++ /dev/null @@ -1,22 +0,0 @@ -= form_for @group_member, url: group_group_members_path(@group), html: { class: 'users-project-form users-group-form' } do |f| - .row - .col-md-4.col-lg-6 - = users_select_tag(:user_ids, group_member_select_options) - .form-text.text-muted.append-bottom-10 - Search for members by name, username, or email, or invite new ones using their email address. - - .col-md-3.col-lg-2 - = select_tag :access_level, options_for_select(GroupMember.access_level_roles, @group_member.access_level), class: "form-control project-access-select" - .form-text.text-muted.append-bottom-10 - = link_to "Read more", help_page_path("user/permissions") - about role permissions - - .col-md-3.col-lg-2 - .clearable-input - = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Expiration date' - %i.clear-icon.js-clear-input - .form-text.text-muted.append-bottom-10 - On this date, the member(s) will automatically lose access to this group and all of its projects. - - .col-md-2 - = f.submit 'Add to group', class: "btn btn-success btn-block", data: { qa_selector: 'add_to_group_button' } diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index 882fcc79421..048edb80d99 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -1,17 +1,28 @@ -- page_title _("Members") +- page_title _("Group members") - can_manage_members = can?(current_user, :admin_group_member, @group) - show_invited_members = can_manage_members && @invited_members.exists? - pending_active = params[:search_invited].present? +- total_count = @members.count + @group.shared_with_group_links.count .project-members-page.prepend-top-default %h4 - = _("Members") + = _("Group members") %hr - if can_manage_members - .project-members-new.append-bottom-default - %p.clearfix - = _("Add new member to %{strong_start}%{group_name}%{strong_end}").html_safe % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } - = render "new_group_member" + - if Feature.enabled?(:share_group_with_group, default_enabled: true) + %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } + %li.nav-tab{ role: 'presentation' } + %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite member") + %li.nav-tab{ role: 'presentation' } + %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab', qa_selector: 'invite_group_tab' }, role: 'tab' }= _("Invite group") + .tab-content.gitlab-tab-content + .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' } + = render_invite_member_for_group(@group, @group_member.access_level) + - if Feature.enabled?(:share_group_with_group, default_enabled: true) + .tab-pane{ id: 'invite-group-pane', role: 'tabpanel' } + = render 'shared/members/invite_group', submit_url: group_group_links_path(@group), access_levels: GroupMember.access_level_roles, default_access_level: @group_member.access_level, group_link_field: 'shared_with_group_id', group_access_field: 'shared_group_access' + - else + = render_invite_member_for_group(@group, @group_member.access_level) = render 'shared/members/requests', membership_source: @group, requesters: @requesters @@ -19,10 +30,10 @@ %ul.nav-links.mobile-separator.nav.nav-tabs.clearfix %li.nav-item - = link_to "#existing_members", class: ["nav-link", ("active" unless pending_active)] , 'data-toggle' => 'tab' do + = link_to "#existing_shares", class: ["nav-link", ("active" unless pending_active)] , 'data-toggle' => 'tab' do %span - = _("Existing") - %span.badge.badge-pill= @members.total_count + = _("Existing shares") + %span.badge.badge-pill= total_count - if show_invited_members %li.nav-item = link_to "#invited_members", class: ["nav-link", ("active" if pending_active)], 'data-toggle' => 'tab' do @@ -31,7 +42,16 @@ %span.badge.badge-pill= @invited_members.total_count .tab-content - #existing_members.tab-pane{ :class => ("active" unless pending_active) } + #existing_shares.tab-pane{ :class => ("active" unless pending_active) } + - if @group.shared_with_group_links.any? + .card.card-without-border + .d-flex.flex-column.flex-md-row.row-content-block.second-block + %span.flex-grow-1.align-self-md-center.col-form-label + = _("Groups with access to %{strong_start}%{group_name}%{strong_end}").html_safe % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + %ul.content-list.members-list{ data: { qa_selector: "groups_list" } } + - can_admin_member = can?(current_user, :admin_group_member, @group) + - @group.shared_with_group_links.each do |group_link| + = render 'shared/members/group', group_link: group_link, can_admin_member: can_admin_member, group_link_path: group_group_link_path(@group, group_link) .card.card-without-border .d-flex.flex-column.flex-md-row.row-content-block.second-block %span.flex-grow-1.align-self-md-center.col-form-label @@ -46,7 +66,7 @@ = label_tag '2fa', '2FA', class: 'col-form-label label-bold pr-md-2' = render 'shared/members/filter_2fa_dropdown' = render 'shared/members/sort_dropdown' - %ul.content-list.members-list + %ul.content-list.members-list{ data: { qa_selector: "members_list" } } = render partial: 'shared/members/member', collection: @members, as: :member = paginate @members, theme: 'gitlab' diff --git a/app/views/groups/settings/_pages_settings.html.haml b/app/views/groups/settings/_pages_settings.html.haml new file mode 100644 index 00000000000..9e1932185da --- /dev/null +++ b/app/views/groups/settings/_pages_settings.html.haml @@ -0,0 +1,5 @@ += form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f| + = render_if_exists 'shared/pages/max_pages_size_input', form: f + + .prepend-top-10 + = f.submit s_('GitLabPages|Save'), class: 'btn btn-success' diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml index 81bd15ed287..8c9b859e127 100644 --- a/app/views/groups/settings/ci_cd/show.html.haml +++ b/app/views/groups/settings/ci_cd/show.html.haml @@ -44,8 +44,10 @@ = expanded ? _('Collapse') : _('Expand') %p - auto_devops_url = help_page_path('topics/autodevops/index') + - quickstart_url = help_page_path('topics/autodevops/quick_start_guide') - auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url } - = s_('GroupSettings|Auto DevOps will automatically build, test and deploy your application based on a predefined Continuous Integration and Delivery configuration. %{auto_devops_start}Learn more about Auto DevOps%{auto_devops_end}').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe } + - quickstart_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: quickstart_url } + = s_('AutoDevOps|Auto DevOps can automatically build, test, and deploy applications based on predefined continuous integration and delivery configuration. %{auto_devops_start}Learn more about Auto DevOps%{auto_devops_end} or use our %{quickstart_start}quick start guide%{quickstart_end} to get started right away.').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe, quickstart_start: quickstart_start, quickstart_end: '</a>'.html_safe } .settings-content = render 'groups/settings/ci_cd/auto_devops_form', group: @group diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml index 518c44cc687..e86d4236be8 100644 --- a/app/views/import/github/new.html.haml +++ b/app/views/import/github/new.html.haml @@ -21,7 +21,7 @@ = form_tag personal_access_token_import_github_path, method: :post do .form-group %label.label-bold= _('Personal Access Token') - = text_field_tag :personal_access_token, '', class: 'form-control', placeholder: _('e.g. %{token}') % { token: '8d3f016698e...' } + = text_field_tag :personal_access_token, '', class: 'form-control', placeholder: _('e.g. %{token}') % { token: '8d3f016698e...' }, data: { qa_selector: 'personal_access_token_field' } %span.form-text.text-muted = import_github_personal_access_token_message @@ -29,4 +29,4 @@ .form-actions.d-flex.justify-content-end = link_to _('Cancel'), new_project_path, class: 'btn' - = submit_tag _('Authenticate'), class: 'btn btn-success ml-2' + = submit_tag _('Authenticate'), class: 'btn btn-success ml-2', data: { qa_selector: 'authenticate_button' } diff --git a/app/views/layouts/_broadcast.html.haml b/app/views/layouts/_broadcast.html.haml index ee3ca824342..9d7ad249ac8 100644 --- a/app/views/layouts/_broadcast.html.haml +++ b/app/views/layouts/_broadcast.html.haml @@ -1,2 +1,4 @@ -- current_broadcast_messages&.each do |message| +- current_broadcast_banner_messages.each do |message| = broadcast_message(message) +- if Feature.enabled?(:broadcast_notification_type) + = broadcast_message(current_broadcast_notification_message) diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml index de1caeaa50f..07c271be2f0 100644 --- a/app/views/layouts/_flash.html.haml +++ b/app/views/layouts/_flash.html.haml @@ -1,10 +1,12 @@ -# We currently only support `alert`, `notice`, `success`, 'toast' +- icons = {'alert' => 'error', 'notice' => 'information-o', 'success' => 'check-circle'}; .flash-container.flash-container-page.sticky - flash.each do |key, value| - if key == 'toast' && value .js-toast-message{ data: { message: value } } - elsif value %div{ class: "flash-#{key} mb-2" } + = sprite_icon(icons[key], size: 16, css_class: 'align-middle mr-1') unless icons[key].nil? %span= value %div{ class: "close-icon-wrapper js-close-icon" } = sprite_icon('close', size: 16, css_class: 'close-icon') diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 0060d8323b0..6b336f3eba2 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -46,7 +46,7 @@ = stylesheet_link_tag "application", media: "all" = stylesheet_link_tag "print", media: "print" - = stylesheet_link_tag "test", media: "all" if Rails.env.test? + = stylesheet_link_tag "disable_animations", media: "all" if Rails.env.test? || Gitlab.config.gitlab['disable_animations'] = stylesheet_link_tag 'performance_bar' if performance_bar_enabled? = stylesheet_link_tag "highlight/themes/#{user_color_scheme}", media: "all" diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index f4ab491a38e..7af190f5a0b 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,5 +1,8 @@ +- page_classes = page_class << @html_class +- page_classes = page_classes.flatten.compact + !!! 5 -%html{ lang: I18n.locale, class: page_class } +%html{ lang: I18n.locale, class: page_classes } = render "layouts/head" %body{ class: "#{user_application_theme} #{@body_class} #{client_class_list}", data: body_data } = render "layouts/init_auto_complete" if @gfm_form diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index 88803f982e8..84906c305a7 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -47,4 +47,4 @@ - if current_user_menu?(:sign_out) %li.divider %li - = link_to _("Sign out"), destroy_user_session_path, class: "sign-out-link", data: { qa_selector: 'sign_out_link' } + = link_to _("Sign out"), destroy_user_session_path, method: :post, class: "sign-out-link", data: { qa_selector: 'sign_out_link' } diff --git a/app/views/layouts/instance_statistics.html.haml b/app/views/layouts/instance_statistics.html.haml index bebd9c4536f..1de6b385c86 100644 --- a/app/views/layouts/instance_statistics.html.haml +++ b/app/views/layouts/instance_statistics.html.haml @@ -1,5 +1,5 @@ -- page_title _('Instance Statistics') -- header_title _('Instance Statistics'), instance_statistics_root_path +- page_title _('Analytics') +- header_title _('Analytics'), instance_statistics_root_path - nav 'instance_statistics' - @left_sidebar = true diff --git a/app/views/layouts/nav/_analytics_link.html.haml b/app/views/layouts/nav/_analytics_link.html.haml new file mode 100644 index 00000000000..f481aeecc1b --- /dev/null +++ b/app/views/layouts/nav/_analytics_link.html.haml @@ -0,0 +1,4 @@ +- return unless dashboard_nav_link?(:analytics) += nav_link(controller: [:dev_ops_score, :cohorts], html_options: { class: "d-none d-xl-block"}) do + = link_to instance_statistics_root_path, class: 'chart-icon', title: _('Analytics'), aria: { label: _('Analytics') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = sprite_icon('chart', size: 18) diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index 9a839765286..379ba976040 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -47,10 +47,7 @@ %li.dropdown = render_if_exists 'dashboard/nav_link_list' - - if can?(current_user, :read_instance_statistics) - = nav_link(controller: [:dev_ops_score, :cohorts]) do - = link_to instance_statistics_root_path do - = _('Instance Statistics') + - if current_user.admin? = nav_link(controller: 'admin/dashboard') do = link_to admin_root_path, class: 'admin-icon qa-admin-area-link d-xl-none' do @@ -58,7 +55,7 @@ - if Feature.enabled?(:user_mode_in_session) - if header_link?(:admin_mode) = nav_link(controller: 'admin/sessions') do - = link_to destroy_admin_session_path, class: 'd-lg-none lock-open-icon' do + = link_to destroy_admin_session_path, method: :post, class: 'd-lg-none lock-open-icon' do = _('Leave Admin Mode') - elsif current_user.admin? = nav_link(controller: 'admin/sessions') do @@ -69,6 +66,8 @@ = link_to sherlock_transactions_path, class: 'admin-icon' do = _('Sherlock Transactions') + = render_if_exists 'layouts/nav/analytics_link' + - if current_user.admin? = nav_link(controller: 'admin/dashboard', html_options: { class: "d-none d-xl-block"}) do = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin Area'), aria: { label: _('Admin Area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do diff --git a/app/views/layouts/nav/sidebar/_analytics_link.html.haml b/app/views/layouts/nav/sidebar/_analytics_link.html.haml new file mode 100644 index 00000000000..9e5ae422e2d --- /dev/null +++ b/app/views/layouts/nav/sidebar/_analytics_link.html.haml @@ -0,0 +1,4 @@ +- return unless dashboard_nav_link?(:analytics) += nav_link(controller: [:dev_ops_score, :cohorts]) do + = link_to instance_statistics_root_path, class: 'd-xl-none' do + = _('Analytics') diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index a027dca1b56..88bb0a97487 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -44,7 +44,7 @@ - if group_sidebar_link?(:contribution_analytics) = nav_link(path: 'analytics#show') do - = link_to group_analytics_path(@group), title: _('Contribution Analytics'), data: { placement: 'right', qa_selector: 'contribution_analytics_link' } do + = link_to group_contribution_analytics_path(@group), title: _('Contribution Analytics'), data: { placement: 'right', qa_selector: 'contribution_analytics_link' } do %span = _('Contribution Analytics') diff --git a/app/views/layouts/nav/sidebar/_instance_statistics.html.haml b/app/views/layouts/nav/sidebar/_instance_statistics.html.haml index 0a84e952442..979d98ec382 100644 --- a/app/views/layouts/nav/sidebar/_instance_statistics.html.haml +++ b/app/views/layouts/nav/sidebar/_instance_statistics.html.haml @@ -1,34 +1,11 @@ .nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) } .nav-sidebar-inner-scroll .context-header - = link_to instance_statistics_root_path, title: _('Instance Statistics') do + = link_to instance_statistics_root_path, title: _('Analytics') do .avatar-container.s40.settings-avatar = sprite_icon('chart', size: 24) - .sidebar-context-title= _('Instance Statistics') + .sidebar-context-title= _('Analytics') %ul.sidebar-top-level-items - = nav_link(controller: :dev_ops_score) do - = link_to instance_statistics_dev_ops_score_index_path do - .nav-icon-container - = sprite_icon('comment') - %span.nav-item-name - = _('DevOps Score') - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :dev_ops_score, html_options: { class: "fly-out-top-item" } ) do - = link_to instance_statistics_dev_ops_score_index_path do - %strong.fly-out-top-item-name - = _('DevOps Score') - - - if Gitlab::CurrentSettings.usage_ping_enabled - = nav_link(controller: :cohorts) do - = link_to instance_statistics_cohorts_path do - .nav-icon-container - = sprite_icon('users') - %span.nav-item-name - = _('Cohorts') - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :cohorts, html_options: { class: "fly-out-top-item" } ) do - = link_to instance_statistics_cohorts_path do - %strong.fly-out-top-item-name - = _('Cohorts') + = render 'layouts/nav/sidebar/instance_statistics_links' = render 'shared/sidebar_toggle_button' diff --git a/app/views/layouts/nav/sidebar/_instance_statistics_links.html.haml b/app/views/layouts/nav/sidebar/_instance_statistics_links.html.haml new file mode 100644 index 00000000000..ee2c83dc31e --- /dev/null +++ b/app/views/layouts/nav/sidebar/_instance_statistics_links.html.haml @@ -0,0 +1,25 @@ +- return unless can?(current_user, :read_instance_statistics) += nav_link(controller: :dev_ops_score) do + = link_to instance_statistics_dev_ops_score_index_path do + .nav-icon-container + = sprite_icon('comment') + %span.nav-item-name + = _('DevOps Score') + %ul.sidebar-sub-level-items.is-fly-out-only + = nav_link(controller: :dev_ops_score, html_options: { class: "fly-out-top-item" } ) do + = link_to instance_statistics_dev_ops_score_index_path do + %strong.fly-out-top-item-name + = _('DevOps Score') + +- if Gitlab::CurrentSettings.usage_ping_enabled + = nav_link(controller: :cohorts) do + = link_to instance_statistics_cohorts_path do + .nav-icon-container + = sprite_icon('users') + %span.nav-item-name + = _('Cohorts') + %ul.sidebar-sub-level-items.is-fly-out-only + = nav_link(controller: :cohorts, html_options: { class: "fly-out-top-item" } ) do + = link_to instance_statistics_cohorts_path do + %strong.fly-out-top-item-name + = _('Cohorts') diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 1e2556aecc1..3464cc1ea07 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -290,6 +290,8 @@ = render_if_exists 'layouts/nav/sidebar/project_packages_link' + = render_if_exists 'layouts/nav/sidebar/project_analytics_link' # EE-specific + - if project_nav_tab? :wiki - wiki_url = project_wiki_path(@project, :home) = nav_link(controller: :wikis) do diff --git a/app/views/profiles/_name.html.haml b/app/views/profiles/_name.html.haml new file mode 100644 index 00000000000..87f1634b4f3 --- /dev/null +++ b/app/views/profiles/_name.html.haml @@ -0,0 +1,5 @@ +- if user.read_only_attribute?(:name) + = form.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9 qa-full-name rspec-full-name' }, + help: s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you") % { provider_label: attribute_provider_label(:name) } +- else + = form.text_field :name, label: s_('Profiles|Full name'), required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead"), wrapper: { class: 'col-md-9 qa-full-name rspec-full-name' }, help: s_("Profiles|Enter your name, so people you know can recognize you") diff --git a/app/views/profiles/active_sessions/_active_session.html.haml b/app/views/profiles/active_sessions/_active_session.html.haml index bb31049111c..f3ad0c4c8ad 100644 --- a/app/views/profiles/active_sessions/_active_session.html.haml +++ b/app/views/profiles/active_sessions/_active_session.html.haml @@ -24,3 +24,9 @@ %strong= _('Signed in') = s_('ProfileSession|on') = l(active_session.created_at, format: :short) + + - unless is_current_session + .float-right + = link_to profile_active_session_path(active_session.public_id), data: { confirm: _('Are you sure? The device will be signed out of GitLab.') }, method: :delete, class: "btn btn-danger prepend-left-10" do + %span.sr-only= _('Revoke') + = _('Revoke') diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml index 1f311e9a4a4..73f6a821b51 100644 --- a/app/views/profiles/notifications/show.html.haml +++ b/app/views/profiles/notifications/show.html.haml @@ -39,12 +39,12 @@ %hr %h5 - = _('Groups (%{count})') % { count: @group_notifications.count } + = _('Groups (%{count})') % { count: @group_notifications.size } %div - @group_notifications.each do |setting| = render 'group_settings', setting: setting, group: setting.source %h5 - = _('Projects (%{count})') % { count: @project_notifications.count } + = _('Projects (%{count})') % { count: @project_notifications.size } %p.account-well = _('To specify the notification level per project of a group you belong to, you need to visit project page and change notification level there.') .append-bottom-default diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index bf76b7379dd..93acd6f550b 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -62,9 +62,13 @@ .form-text.text-muted = s_('Preferences|Choose what content you want to see on a projectās overview page.') .form-group.form-check + = f.check_box :render_whitespace_in_code, class: 'form-check-input' + = f.label :render_whitespace_in_code, class: 'form-check-label' do + = s_('Preferences|Render whitespace characters in the Web IDE') + .form-group.form-check = f.check_box :show_whitespace_in_diffs, class: 'form-check-input' = f.label :show_whitespace_in_diffs, class: 'form-check-label' do - = s_('Preferences|Show whitespace in diffs') + = s_('Preferences|Show whitespace changes in diffs') .col-sm-12 %hr diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index cfad274f91d..49533c18c8f 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -88,11 +88,7 @@ = s_("Profiles|Some options are unavailable for LDAP accounts") .col-lg-8 .row - - if @user.read_only_attribute?(:name) - = f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9 qa-full-name rspec-full-name' }, - help: s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you") % { provider_label: attribute_provider_label(:name) } - - else - = f.text_field :name, label: s_('Profiles|Full name'), required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead"), wrapper: { class: 'col-md-9 qa-full-name rspec-full-name' }, help: s_("Profiles|Enter your name, so people you know can recognize you") + = render 'profiles/name', form: f, user: @user = f.text_field :id, readonly: true, label: s_('Profiles|User ID'), wrapper: { class: 'col-md-3' } = f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, { prompt: _('Select your role') }, required: true, class: 'input-md' diff --git a/app/views/projects/_merge_request_merge_suggestions_settings.html.haml b/app/views/projects/_merge_request_merge_suggestions_settings.html.haml new file mode 100644 index 00000000000..06bb9056e61 --- /dev/null +++ b/app/views/projects/_merge_request_merge_suggestions_settings.html.haml @@ -0,0 +1,17 @@ +- form = local_assigns.fetch(:form) + +.form-group + %b= s_('ProjectSettings|Merge suggestions') + %p.text-secondary + = s_('ProjectSettings|The commit message used to apply merge request suggestions') + = link_to icon('question-circle'), + help_page_path('user/discussions/index.md', + anchor: 'configure-the-commit-message-for-applied-suggestions'), + target: '_blank' + .mb-2 + = form.text_field :suggestion_commit_message, class: 'form-control mb-2', placeholder: Suggestions::ApplyService::DEFAULT_SUGGESTION_COMMIT_MESSAGE + %p.form-text.text-muted + = s_('ProjectSettings|The variables GitLab supports:') + - Suggestions::ApplyService::PLACEHOLDERS.keys.each do |placeholder| + %code + = "%{#{placeholder}}".html_safe diff --git a/app/views/projects/_merge_request_settings.html.haml b/app/views/projects/_merge_request_settings.html.haml index f2ba38387a3..dc3a3fcc647 100644 --- a/app/views/projects/_merge_request_settings.html.haml +++ b/app/views/projects/_merge_request_settings.html.haml @@ -5,3 +5,5 @@ = render 'projects/merge_request_merge_options_settings', project: @project, form: form = render 'projects/merge_request_merge_checks_settings', project: @project, form: form + += render 'projects/merge_request_merge_suggestions_settings', project: @project, form: form diff --git a/app/views/projects/_merge_request_settings_description_text.html.haml b/app/views/projects/_merge_request_settings_description_text.html.haml index 42964c900b3..dc9dc92675d 100644 --- a/app/views/projects/_merge_request_settings_description_text.html.haml +++ b/app/views/projects/_merge_request_settings_description_text.html.haml @@ -1 +1 @@ -%p= s_('ProjectSettings|Choose your merge method, merge options, and merge checks.') +%p= s_('ProjectSettings|Choose your merge method, merge options, merge checks, and merge suggestions.') diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index 30fe5622ebd..b17207c0da6 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -20,8 +20,14 @@ .commit-row-title %span.item-title.str-truncated-100 = link_to commit.title, project_commit_path(@project, commit.id), class: "cdark", title: commit.title - .float-right - = link_to commit.short_id, project_commit_path(@project, commit), class: "commit-sha" + %span + - previous_commit_id = commit.parent_id + - if previous_commit_id + = link_to project_blame_path(@project, tree_join(previous_commit_id, @path)), + title: _('View blame prior to this change'), + aria: { label: _('View blame prior to this change') }, + data: { toggle: 'tooltip', placement: 'right', container: 'body' } do + = sprite_icon('doc-versions', size: 16, css_class: 'doc-versions align-text-bottom') .light = commit_author_link(commit, avatar: false) diff --git a/app/views/projects/blob/_render_error.html.haml b/app/views/projects/blob/_render_error.html.haml index 9eef6cafd04..1ff68cd2d11 100644 --- a/app/views/projects/blob/_render_error.html.haml +++ b/app/views/projects/blob/_render_error.html.haml @@ -3,5 +3,5 @@ The #{viewer.switcher_title} could not be displayed because #{blob_render_error_reason(viewer)}. You can - = blob_render_error_options(viewer).to_sentence(two_words_connector: ' or ', last_word_connector: ', or ').html_safe + = Gitlab::Utils.to_exclusive_sentence(blob_render_error_options(viewer)).html_safe instead. diff --git a/app/views/projects/blob/viewers/_contributing.html.haml b/app/views/projects/blob/viewers/_contributing.html.haml index c78f04c9c7c..546c064c06f 100644 --- a/app/views/projects/blob/viewers/_contributing.html.haml +++ b/app/views/projects/blob/viewers/_contributing.html.haml @@ -4,6 +4,6 @@ After you've reviewed these contribution guidelines, you'll be all set to - options = contribution_options(viewer.project) - if options.any? = succeed '.' do - = options.to_sentence(two_words_connector: ' or ', last_word_connector: ', or ').html_safe + = Gitlab::Utils.to_exclusive_sentence(options).html_safe - else contribute to this project. diff --git a/app/views/projects/ci/lints/_create.html.haml b/app/views/projects/ci/lints/_create.html.haml index 59b5b9f8a30..d65c06aa2a4 100644 --- a/app/views/projects/ci/lints/_create.html.haml +++ b/app/views/projects/ci/lints/_create.html.haml @@ -1,8 +1,8 @@ - if @status - %p - %b= _("Status:") - = _("syntax is correct") - %i.fa.fa-ok.correct-syntax + .bs-callout.bs-callout-success + %p + %b= _("Status:") + = _("syntax is correct") .table-holder %table.table.table-bordered @@ -40,9 +40,10 @@ %b= _("Allowed to fail") - else - %p - %b= _("Status:") - = _("syntax is incorrect") - %i.fa.fa-remove.incorrect-syntax - %b= _("Error:") - = @error + .bs-callout.bs-callout-danger + %p + %b= _("Status:") + = _("syntax is incorrect") + %pre + - @errors.each do |message| + %p= message diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 3a9c7a8bec5..8b659034fe6 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -21,9 +21,9 @@ .commit-detail.flex-list .commit-content.qa-commit-content - if view_details && merge_request - = link_to commit.title, project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: "commit-row-message item-title js-onboarding-commit-item" + = link_to commit.title, project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: ["commit-row-message item-title js-onboarding-commit-item", ("font-italic" if commit.message.empty?)] - else - = link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title js-onboarding-commit-item") + = link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title js-onboarding-commit-item #{"font-italic" if commit.message.empty?}") %span.commit-row-message.d-inline.d-sm-none · = commit.short_id diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index 1691af9dfdd..8bbe4e66c50 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -50,7 +50,7 @@ %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The collection of events added to the data gathered for that stage."), "aria-hidden" => "true" } %li.total-time-header.pr-5.text-right %span.stage-name.font-weight-bold - {{ __('Total Time') }} + {{ __('Time') }} %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The time taken by each data entry gathered by that stage."), "aria-hidden" => "true" } .stage-panel-body %nav.stage-nav diff --git a/app/views/projects/default_branch/_show.html.haml b/app/views/projects/default_branch/_show.html.haml index 59efcde5825..6a09004143e 100644 --- a/app/views/projects/default_branch/_show.html.haml +++ b/app/views/projects/default_branch/_show.html.haml @@ -9,13 +9,23 @@ = _('Select the branch you want to set as the default for this project. All merge requests and commits will automatically be made against this branch unless you specify a different one.') .settings-content - - if @project.empty_repo? - .text-secondary - = _('A default branch cannot be chosen for an empty project.') - - else - = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, anchor: 'default-branch-settings' }, authenticity_token: true do |f| - %fieldset + = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, anchor: 'default-branch-settings' }, authenticity_token: true do |f| + %fieldset + - if @project.empty_repo? + .text-secondary + = _('A default branch cannot be chosen for an empty project.') + - else .form-group = f.label :default_branch, "Default Branch", class: 'label-bold' = f.select(:default_branch, @project.repository.branch_names, {}, {class: 'select2 select-wide'}) - = f.submit 'Save changes', class: "btn btn-success" + + .form-group + .form-check + = f.check_box :autoclose_referenced_issues, class: 'form-check-input' + = f.label :autoclose_referenced_issues, class: 'form-check-label' do + %strong= _("Auto-close referenced issues on default branch") + .form-text.text-muted + = _("Issues referenced by merge requests and commits within the default branch will be closed automatically") + = link_to icon('question-circle'), help_page_path('user/project/issues/managing_issues.html', anchor: 'disabling-automatic-issue-closing'), target: '_blank' + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/projects/diffs/_viewer.html.haml b/app/views/projects/diffs/_viewer.html.haml index 5c4d1760871..37ff03b4a59 100644 --- a/app/views/projects/diffs/_viewer.html.haml +++ b/app/views/projects/diffs/_viewer.html.haml @@ -3,8 +3,6 @@ .diff-viewer{ data: { type: viewer.type }, class: ('hidden' if hidden) } - if viewer.render_error = render 'projects/diffs/render_error', viewer: viewer - - elsif viewer.collapsed? - = render 'projects/diffs/collapsed', viewer: viewer - else - viewer.prepare! diff --git a/app/views/projects/diffs/_collapsed.html.haml b/app/views/projects/diffs/viewers/_collapsed.html.haml index 94dcda38bd6..94dcda38bd6 100644 --- a/app/views/projects/diffs/_collapsed.html.haml +++ b/app/views/projects/diffs/viewers/_collapsed.html.haml diff --git a/app/views/projects/environments/_pin_button.html.haml b/app/views/projects/environments/_pin_button.html.haml new file mode 100644 index 00000000000..5c7bfc2b17b --- /dev/null +++ b/app/views/projects/environments/_pin_button.html.haml @@ -0,0 +1,3 @@ +- if environment.auto_stop_at? && environment.available? + = button_to cancel_auto_stop_project_environment_path(environment.project, environment), class: 'btn btn-secondary has-tooltip', title: _('Prevent environment from auto-stopping') do + = sprite_icon('thumbtack') diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index 62b1c140794..ff78abfddf4 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -32,9 +32,14 @@ = button_to stop_project_environment_path(@project, @environment), class: 'btn btn-danger has-tooltip', method: :post do = s_('Environments|Stop environment') -.top-area - %h3.page-title= @environment.name - .nav-controls.ml-auto.my-2 +.top-area.justify-content-between + .d-flex + %h3.page-title= @environment.name + - if @environment.auto_stop_at? + %p.align-self-end.prepend-left-8 + = s_('Environments|Auto stops %{auto_stop_time}').html_safe % {auto_stop_time: time_ago_with_tooltip(@environment.auto_stop_at)} + .nav-controls.my-2 + = render 'projects/environments/pin_button', environment: @environment = render 'projects/environments/terminal_button', environment: @environment = render 'projects/environments/external_url', environment: @environment = render 'projects/environments/metrics_button', environment: @environment diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml index 2a2ccf8a6de..93a43b5d1ea 100644 --- a/app/views/projects/graphs/charts.html.haml +++ b/app/views/projects/graphs/charts.html.haml @@ -4,6 +4,9 @@ %h4.sub-header = _("Programming languages used in this repository") + %p + = _("Measured in bytes of code. Excludes generated and vendored code.") + .row .col-md-4 %ul.bordered-list diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 17f6fe95f10..9062f2097b8 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -71,6 +71,9 @@ = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago') + - if @issue.sentry_issue.present? + #js-sentry-error-stack-trace{ data: error_details_data(@project, @issue.sentry_issue.sentry_issue_identifier) } + = render_if_exists 'projects/issues/related_issues' #js-related-merge-requests{ data: { endpoint: expose_path(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: @issue.iid)), project_namespace: @project.namespace.path, project_path: @project.path } } diff --git a/app/views/projects/pages/_https_only.html.haml b/app/views/projects/pages/_https_only.html.haml deleted file mode 100644 index d8c4a5f0a5d..00000000000 --- a/app/views/projects/pages/_https_only.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -= form_for @project, url: namespace_project_pages_path(@project.namespace.becomes(Namespace), @project), html: { class: 'inline', title: pages_https_only_title } do |f| - .form-group - .form-check - = f.check_box :pages_https_only, class: 'form-check-input', disabled: pages_https_only_disabled? - = f.label :pages_https_only, class: pages_https_only_label_class do - %strong - = s_('GitLabPages|Force HTTPS (requires valid certificates)') - - - unless pages_https_only_disabled? - .prepend-top-10 - = f.submit s_('GitLabPages|Save'), class: 'btn btn-success' diff --git a/app/views/projects/pages/_pages_settings.html.haml b/app/views/projects/pages/_pages_settings.html.haml new file mode 100644 index 00000000000..58eddf630f4 --- /dev/null +++ b/app/views/projects/pages/_pages_settings.html.haml @@ -0,0 +1,13 @@ += form_for @project, url: namespace_project_pages_path(@project.namespace.becomes(Namespace), @project), html: { class: 'inline', title: pages_https_only_title } do |f| + - if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https + = render_if_exists 'shared/pages/max_pages_size_input', form: f + + .form-group + .form-check + = f.check_box :pages_https_only, class: 'form-check-input', disabled: pages_https_only_disabled? + = f.label :pages_https_only, class: pages_https_only_label_class do + %strong + = s_('GitLabPages|Force HTTPS (requires valid certificates)') + + .prepend-top-10 + = f.submit s_('GitLabPages|Save'), class: 'btn btn-success' diff --git a/app/views/projects/pages/_ssl_limitations_warning.html.haml b/app/views/projects/pages/_ssl_limitations_warning.html.haml new file mode 100644 index 00000000000..7188e169824 --- /dev/null +++ b/app/views/projects/pages/_ssl_limitations_warning.html.haml @@ -0,0 +1,7 @@ +.bs-callout.bs-callout-warning + %i.fa.fa-warning + %strong= _("Warning:") + - pages_host = Gitlab.config.pages.host + = s_("GitLabPages|When using Pages under the general domain of a GitLab instance (%{pages_host}), you cannot use HTTPS with sub-subdomains. This means that if your username/groupname contains a dot it will not work. This is a limitation of the HTTP Over TLS protocol. HTTP pages will continue to work provided you don't redirect HTTP to HTTPS.").html_safe % { pages_host: pages_host } + + %strong= external_link(s_("GitLabPages|Learn more."), "https://docs.gitlab.com/ee/user/project/pages/introduction.html#limitations") diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml index 3ec87597849..4b7810ea357 100644 --- a/app/views/projects/pages/show.html.haml +++ b/app/views/projects/pages/show.html.haml @@ -10,11 +10,12 @@ %p.light = s_('GitLabPages|With GitLab Pages you can host your static websites on GitLab. Combined with the power of GitLab CI and the help of GitLab Runner you can deploy static pages for your individual projects, your user or your group.') - - if Gitlab.config.pages.external_https - = render 'https_only' + + = render 'pages_settings' %hr.clearfix + = render 'ssl_limitations_warning' if @project.pages_subdomain.include?(".") = render 'access' = render 'use' - if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https diff --git a/app/views/projects/project_members/_groups.html.haml b/app/views/projects/project_members/_groups.html.haml index 00321014f91..353c36d0fed 100644 --- a/app/views/projects/project_members/_groups.html.haml +++ b/app/views/projects/project_members/_groups.html.haml @@ -3,4 +3,6 @@ = _("Groups with access to <strong>%{project_name}</strong>").html_safe % { project_name: sanitize(@project.name, tags: []) } %span.badge.badge-pill= group_links.size %ul.content-list.members-list - = render partial: 'shared/members/group', collection: group_links, as: :group_link + - can_admin_member = can?(current_user, :admin_project_member, @project) + - @group_links.each do |group_link| + = render 'shared/members/group', group_link: group_link, can_admin_member: can_admin_member, group_link_path: project_group_link_path(@project, group_link) diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml index 5310c1fad01..5d8005b2e2a 100644 --- a/app/views/projects/project_members/_team.html.haml +++ b/app/views/projects/project_members/_team.html.haml @@ -13,5 +13,5 @@ %button.user-search-btn{ type: "submit", "aria-label" => _("Submit search") } = icon("search") = render 'shared/members/sort_dropdown' - %ul.content-list.members-list.qa-members-list + %ul.content-list.members-list{ data: { qa_selector: 'members_list' } } = render partial: 'shared/members/member', collection: members, as: :member diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 24fe583a9b5..c24a9061146 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -23,13 +23,13 @@ .tab-content.gitlab-tab-content .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' } - = render 'projects/project_members/new_project_member', tab_title: _('Invite member') + = render 'shared/members/invite_member', submit_url: project_project_members_path(@project), access_levels: ProjectMember.access_level_roles, default_access_level: @project_member.access_level, can_import_members?: can_import_members?, import_path: import_project_project_members_path(@project) .tab-pane{ id: 'invite-group-pane', role: 'tabpanel', class: ('active' if membership_locked?) } - = render 'projects/project_members/new_project_group', tab_title: _('Invite group') + = render 'shared/members/invite_group', submit_url: project_group_links_path(@project), access_levels: ProjectGroupLink.access_options, default_access_level: ProjectGroupLink.default_access, group_link_field: 'link_group_id', group_access_field: 'link_group_access' - elsif !membership_locked? - .invite-member= render 'projects/project_members/new_project_member', tab_title: _('Invite member') + .invite-member= render 'shared/members/invite_member', submit_url: project_project_members_path(@project), access_levels: ProjectMember.access_level_roles, default_access_level: @project_member.access_level, can_import_members?: can_import_members?, import_path: import_project_project_members_path(@project) - elsif @project.allowed_to_share_with_group? - .invite-group= render 'projects/project_members/new_project_group', tab_title: _('Invite group') + .invite-group= render 'shared/members/invite_group', access_levels: ProjectGroupLink.access_options, default_access_level: ProjectGroupLink.default_access, submit_url: project_group_links_path(@project), group_link_field: 'link_group_id', group_access_field: 'link_group_access' = render 'shared/members/requests', membership_source: @project, requesters: @requesters .clearfix diff --git a/app/views/projects/registry/settings/_index.haml b/app/views/projects/registry/settings/_index.haml index e1eed93664e..0e0341a9923 100644 --- a/app/views/projects/registry/settings/_index.haml +++ b/app/views/projects/registry/settings/_index.haml @@ -1,2 +1,4 @@ -#js-registry-settings{ data: { registry_settings_endpoint: '', - help_page_path: help_page_path('user/project/operations/linking_to_an_external_dashboard') } } +#js-registry-settings{ data: { project_id: @project.id, + cadence_options: cadence_options.to_json, + keep_n_options: keep_n_options.to_json, + older_than_options: older_than_options.to_json} } diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 38483f599b7..a65afeecc17 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -23,8 +23,11 @@ %button.btn.btn-default.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p - = s_('CICD|Auto DevOps will automatically build, test, and deploy your application based on a predefined Continuous Integration and Delivery configuration.') - = link_to s_('CICD|Learn more about Auto DevOps'), help_page_path('topics/autodevops/index.md') + - auto_devops_url = help_page_path('topics/autodevops/index') + - quickstart_url = help_page_path('topics/autodevops/quick_start_guide') + - auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url } + - quickstart_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: quickstart_url } + = s_('AutoDevOps|Auto DevOps can automatically build, test, and deploy applications based on predefined continuous integration and delivery configuration. %{auto_devops_start}Learn more about Auto DevOps%{auto_devops_end} or use our %{quickstart_start}quick start guide%{quickstart_end} to get started right away.').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe, quickstart_start: quickstart_start, quickstart_end: '</a>'.html_safe } .settings-content = render 'autodevops_form', auto_devops_enabled: @project.auto_devops_enabled? @@ -60,13 +63,14 @@ = render 'projects/triggers/index' - if Feature.enabled?(:registry_retention_policies_settings, @project) - %section.settings.no-animate#js-registry-polcies{ class: ('expanded' if expanded) } + %section.settings.no-animate#js-registry-policies{ class: ('expanded' if expanded) } .settings-header %h4 - = _("Container Registry tag expiration policies") + = _("Container Registry tag expiration policy") + = link_to icon('question-circle'), help_page_path('user/packages/container_registry/index', anchor: 'retention-and-expiration-policy'), target: '_blank', rel: 'noopener noreferrer' %button.btn.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p - = _("Expiration policies for the Container Registry are a perfect solution for keeping the Registry space down while still enjoying the full power of GitLab CI/CD.") + = _("Expiration policy for the Container Registry is a perfect solution for keeping the Registry space down while still enjoying the full power of GitLab CI/CD.") .settings-content = render 'projects/registry/settings/index' diff --git a/app/views/projects/settings/operations/_error_tracking.html.haml b/app/views/projects/settings/operations/_error_tracking.html.haml index 589d3037eba..06b5243dfd9 100644 --- a/app/views/projects/settings/operations/_error_tracking.html.haml +++ b/app/views/projects/settings/operations/_error_tracking.html.haml @@ -12,7 +12,7 @@ = _('To link Sentry to GitLab, enter your Sentry URL and Auth Token.') = link_to _('More information'), help_page_path('user/project/operations/error_tracking'), target: '_blank', rel: 'noopener noreferrer' .settings-content - .js-error-tracking-form{ data: { list_projects_endpoint: list_projects_project_error_tracking_index_path(@project, format: :json), + .js-error-tracking-form{ data: { list_projects_endpoint: project_error_tracking_projects_path(@project, format: :json), operations_settings_endpoint: project_settings_operations_path(@project), project: error_tracking_setting_project_json, api_host: setting.api_host, diff --git a/app/views/projects/triggers/_content.html.haml b/app/views/projects/triggers/_content.html.haml deleted file mode 100644 index e686068657c..00000000000 --- a/app/views/projects/triggers/_content.html.haml +++ /dev/null @@ -1,9 +0,0 @@ -- if Feature.enabled?(:use_legacy_pipeline_triggers, @project) - %p.append-bottom-default - Triggers with the - %span.badge.badge-primary legacy - label do not have an associated user and only have access to the current project. - %br - = succeed '.' do - Learn more in the - = link_to 'triggers documentation', help_page_path('ci/triggers/README'), target: '_blank' diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml index a559ce41e57..55a9234f01a 100644 --- a/app/views/projects/triggers/_index.html.haml +++ b/app/views/projects/triggers/_index.html.haml @@ -1,6 +1,5 @@ .row.prepend-top-default.append-bottom-default.triggers-container .col-lg-12 - = render "projects/triggers/content" .card .card-header Manage your project's triggers diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml index 60de3630bb5..d80248f7e80 100644 --- a/app/views/projects/triggers/_trigger.html.haml +++ b/app/views/projects/triggers/_trigger.html.haml @@ -7,12 +7,7 @@ %span= trigger.short_token .label-container - - if trigger.legacy? - - if trigger.supports_legacy_tokens? - %span.badge.badge-primary.has-tooltip{ title: "Trigger makes use of deprecated functionality" } legacy - - else - %span.badge.badge-danger.has-tooltip{ title: "Trigger is invalid due to being a legacy trigger. We recommend replacing it with a new trigger" } invalid - - elsif !trigger.can_access_project? + - unless trigger.can_access_project? %span.badge.badge-danger.has-tooltip{ title: "Trigger user has insufficient permissions to project" } invalid %td diff --git a/app/views/projects/triggers/edit.html.haml b/app/views/projects/triggers/edit.html.haml index c35df322b9d..0f74d733c06 100644 --- a/app/views/projects/triggers/edit.html.haml +++ b/app/views/projects/triggers/edit.html.haml @@ -1,9 +1,7 @@ - page_title "Trigger" .row.prepend-top-default.append-bottom-default - .col-lg-3 - = render "content" - .col-lg-9 + .col-lg-12 %h4.prepend-top-0 Update trigger = render "form", btn_text: "Save trigger" diff --git a/app/views/registrations/welcome.html.haml b/app/views/registrations/welcome.html.haml index 7b92f5070df..bc8d7ed10ef 100644 --- a/app/views/registrations/welcome.html.haml +++ b/app/views/registrations/welcome.html.haml @@ -1,5 +1,4 @@ -- content_for(:page_title, _('Welcome to GitLab @%{username}!') % { username: current_user.username }) -- max_name_length = 128 +- content_for(:page_title, _('Welcome to GitLab %{name}!') % { name: current_user.name }) .text-center.mb-3 = _('In order to tailor your experience with GitLab we<br>would like to know a bit more about you.').html_safe .signup-box.p-3.mb-2 @@ -7,9 +6,6 @@ = form_for(current_user, url: users_sign_up_update_registration_path, html: { class: 'new_new_user gl-show-field-errors', 'aria-live' => 'assertive' }) do |f| .devise-errors.mt-0 = render 'devise/shared/error_messages', resource: current_user - .name.form-group - = f.label :name, _('Full name'), class: 'label-bold' - = f.text_field :name, class: 'form-control top js-block-emoji js-validate-length', :data => { :max_length => max_name_length, :max_length_message => s_('Name is too long (maximum is %{max_length} characters).') % { max_length: max_name_length }, :qa_selector => 'new_user_name_field' }, required: true, title: _('This field is required.') .form-group = f.label :role, _('Role'), class: 'label-bold' = f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, {}, class: 'form-control' diff --git a/app/views/shared/_auto_devops_callout.html.haml b/app/views/shared/_auto_devops_callout.html.haml index 084d295f2c1..128508e954e 100644 --- a/app/views/shared/_auto_devops_callout.html.haml +++ b/app/views/shared/_auto_devops_callout.html.haml @@ -1,16 +1,15 @@ -.js-autodevops-banner.banner-callout.banner-non-empty-state.append-bottom-20.prepend-top-10{ data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } } - .banner-graphic - = custom_icon('icon_autodevops') +%section.js-autodevops-banner.gl-banner{ data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } } + .gl-banner-illustration + = image_tag('illustrations/autodevops.svg') - .banner-body.prepend-left-10.append-bottom-10 - %h5.banner-title= s_('AutoDevOps|Auto DevOps') + .gl-banner-content + %h1.gl-banner-title= s_('AutoDevOps|Auto DevOps') %p= s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.') %p - link = link_to(s_('AutoDevOps|Auto DevOps documentation'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer') = s_('AutoDevOps|Learn more in the %{link_to_documentation}').html_safe % { link_to_documentation: link } - .banner-buttons - = link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'autodevops-settings'), class: 'btn js-close-callout' + = link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'autodevops-settings'), class: 'btn btn-md new-gl-button js-close-callout' - %button.btn-transparent.banner-close.close.js-close-callout{ type: 'button', + %button.gl-banner-close.close.js-close-callout{ type: 'button', 'aria-label' => 'Dismiss Auto DevOps box' } = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true') diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml index eb9b7f6c48a..a62c385d711 100644 --- a/app/views/shared/boards/components/_board.html.haml +++ b/app/views/shared/boards/components/_board.html.haml @@ -42,9 +42,10 @@ %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.pr-0.no-drag.text-secondary{ "v-if" => "showBoardListAndBoardInfo", ":title": "counterTooltip", "v-tooltip": true, data: { placement: "top" } } + .issue-count-badge.pr-0.no-drag.text-secondary{ "v-if" => "showBoardListAndBoardInfo" } %span.d-inline-flex - %span.issue-count-badge-count + %gl-tooltip{ ":target" => "() => $refs.issueCount", ":title" => "issuesTooltip" } + %span.issue-count-badge-count{ "ref" => "issueCount" } %icon.mr-1{ name: "issues" } %issue-count{ ":maxIssueCount" => "list.maxIssueCount", ":issuesSize" => "list.issuesSize" } diff --git a/app/views/shared/file_hooks/_index.html.haml b/app/views/shared/file_hooks/_index.html.haml new file mode 100644 index 00000000000..74eb6c94116 --- /dev/null +++ b/app/views/shared/file_hooks/_index.html.haml @@ -0,0 +1,24 @@ +- file_hooks = Gitlab::FileHook.files + +.row.prepend-top-default + .col-lg-4 + %h4.prepend-top-0 + = _('File Hooks') + %p + = _('File hooks are similar to system hooks but are executed as files instead of sending data to a URL.') + = link_to _('For more information, see the File Hooks documentation.'), help_page_path('administration/file_hooks') + + + .col-lg-8.append-bottom-default + - if file_hooks.any? + .card + .card-header + = _('File Hooks (%{count})') % { count: file_hooks.count } + %ul.content-list + - file_hooks.each do |file| + %li + .monospace + = File.basename(file) + - else + .card.bg-light.text-center + .nothing-here-block= _('No file hooks found.') diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml index 9db6184ebca..2f2e6d83f9f 100644 --- a/app/views/shared/form_elements/_description.html.haml +++ b/app/views/shared/form_elements/_description.html.haml @@ -1,6 +1,8 @@ - project = local_assigns.fetch(:project) - model = local_assigns.fetch(:model) + + - form = local_assigns.fetch(:form) - placeholder = model.is_a?(MergeRequest) ? _('Describe the goal of the changes and what reviewers should be aware of.') : _('Write a comment or drag your files hereā¦') - supports_quick_actions = model.new_record? @@ -14,6 +16,8 @@ = form.label :description, 'Description', class: 'col-form-label col-sm-2' .col-sm-10 + - if model.is_a?(Issuable) + = render 'shared/issuable/form/template_selector', issuable: model = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do = render 'projects/zen', f: form, attr: :description, classes: 'note-textarea qa-issuable-form-description rspec-issuable-form-description', diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 0fb23adc31f..a020a04e366 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -17,7 +17,6 @@ .form-group.row = form.label :title, class: 'col-form-label col-sm-2' - = render 'shared/issuable/form/template_selector', issuable: issuable = render 'shared/issuable/form/title', issuable: issuable, form: form, has_wip_commits: commits && commits.detect(&:work_in_progress?) #js-suggestions{ data: { project_path: @project.full_path } } diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 5da86195243..c3960ec5026 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -1,5 +1,6 @@ - type = local_assigns.fetch(:type) - board = local_assigns.fetch(:board, nil) +- show_sorting_dropdown = local_assigns.fetch(:show_sorting_dropdown, true) - is_not_boards_modal_or_productivity_analytics = type != :boards_modal && type != :productivity_analytics - block_css_class = is_not_boards_modal_or_productivity_analytics ? 'row-content-block second-block' : '' - user_can_admin_list = board && can?(current_user, :admin_list, board.resource_parent) @@ -30,23 +31,22 @@ %li.input-token %input.form-control.filtered-search{ search_filter_input_options(type) } #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown - %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { action: 'submit' } } - %button.btn.btn-link{ type: 'button' } - = sprite_icon('search') - %span - = _('Press Enter or click to search') %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item + %li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } } %button.btn.btn-link{ type: 'button' } -# Encapsulate static class name `{{icon}}` inside #{} to bypass -# haml lint's ClassAttributeWithStaticValue %svg %use{ 'xlink:href': "#{'{{icon}}'}" } %span.js-filter-hint - {{hint}} - %span.js-filter-tag.dropdown-light-content - {{tag}} + {{formattedKey}} + #js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu + %ul.filter-dropdown{ data: { dropdown: true, dynamic: true } } + %li.filter-dropdown-item{ data: { value: "{{ title }}" } } + %button.btn.btn-link{ type: 'button' } + {{ title }} + %span.btn-helptext + {{ help }} #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu - if current_user %ul{ data: { dropdown: true } } @@ -170,5 +170,5 @@ - if @project #js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } } #js-toggle-focus-btn - - elsif is_not_boards_modal_or_productivity_analytics + - elsif is_not_boards_modal_or_productivity_analytics && show_sorting_dropdown = render 'shared/issuable/sort_dropdown' diff --git a/app/views/shared/issuable/form/_template_selector.html.haml b/app/views/shared/issuable/form/_template_selector.html.haml index d613bd31d81..bf34ea4a1b2 100644 --- a/app/views/shared/issuable/form/_template_selector.html.haml +++ b/app/views/shared/issuable/form/_template_selector.html.haml @@ -2,7 +2,7 @@ - return unless issuable && issuable_templates(issuable).any? -.col-sm-3.col-lg-2 +.issuable-form-select-holder.selectbox.form-group .js-issuable-selector-wrap{ data: { issuable_type: issuable.to_ability_name } } = template_dropdown_tag(issuable) do %ul.dropdown-footer-list diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml index 18368ecc9ff..4aeeac87f3c 100644 --- a/app/views/shared/members/_group.html.haml +++ b/app/views/shared/members/_group.html.haml @@ -1,6 +1,7 @@ - group_link = local_assigns[:group_link] -- group = group_link.group -- can_admin_member = can?(current_user, :admin_project_member, @project) +- group = group_link.shared_with_group +- can_admin_member = local_assigns[:can_admin_member] +- group_link_path = local_assigns[:group_link_path] - dom_id = "group_member_#{group_link.id}" -# Note this is just for groups. For individual members please see shared/members/_member @@ -17,7 +18,7 @@ %span{ class: ('text-warning' if group_link.expires_soon?) } = _("Expires in %{expires_at}").html_safe % { expires_at: distance_of_time_in_words_to_now(group_link.expires_at) } .controls.member-controls.align-items-center - = form_tag project_group_link_path(@project, group_link), method: :put, remote: true, class: 'js-edit-member-form form-group d-sm-flex' do + = form_tag group_link_path, method: :put, remote: true, class: 'js-edit-member-form form-group d-sm-flex' do = hidden_field_tag "group_link[group_access]", group_link.group_access .member-form-control.dropdown.mr-sm-2.d-sm-inline-block %button.dropdown-menu-toggle.js-member-permissions-dropdown{ type: "button", @@ -39,7 +40,7 @@ = text_field_tag 'group_link[expires_at]', group_link.expires_at, class: 'form-control js-access-expiration-date js-member-update-control', placeholder: _('Expiration date'), id: "member_expires_at_#{group.id}", disabled: !can_admin_member %i.clear-icon.js-clear-input - if can_admin_member - = link_to project_group_link_path(@project, group_link), + = link_to group_link_path, method: :delete, data: { confirm: _("Are you sure you want to remove %{group_name}?") % { group_name: group.name }, qa_selector: 'delete_group_access_link' }, class: 'btn btn-remove m-0 ml-sm-2 align-self-center' do diff --git a/app/views/projects/project_members/_new_project_group.html.haml b/app/views/shared/members/_invite_group.html.haml index d413048ca10..27c930bcbb5 100644 --- a/app/views/projects/project_members/_new_project_group.html.haml +++ b/app/views/shared/members/_invite_group.html.haml @@ -1,13 +1,18 @@ +- access_levels = local_assigns[:access_levels] +- default_access_level = local_assigns[:default_access_level] +- submit_url = local_assigns[:submit_url] +- group_link_field = local_assigns[:group_link_field] +- group_access_field = local_assigns[:group_access_field] .row .col-sm-12 - = form_tag project_group_links_path(@project), class: 'js-requires-input', method: :post do + = form_tag submit_url, class: 'invite-group-form js-requires-input', method: :post do .form-group - = label_tag :link_group_id, _("Select a group to invite"), class: "label-bold" - = groups_select_tag(:link_group_id, data: { skip_groups: @skip_groups }, class: "input-clamp qa-group-select-field", required: true) + = label_tag group_link_field, _("Select a group to invite"), class: "label-bold" + = groups_select_tag(group_link_field, data: { skip_groups: @skip_groups }, class: 'input-clamp qa-group-select-field', required: true) .form-group - = label_tag :link_group_access, _("Max access level"), class: "label-bold" + = label_tag group_access_field, _("Max access level"), class: "label-bold" .select-wrapper - = select_tag :link_group_access, options_for_select(ProjectGroupLink.access_options, ProjectGroupLink.default_access), class: "form-control select-control" + = select_tag group_access_field, options_for_select(access_levels, default_access_level), data: { qa_selector: 'group_access_field' }, class: "form-control select-control" = icon('chevron-down') .form-text.text-muted.append-bottom-10 - permissions_docs_path = help_page_path('user/permissions') diff --git a/app/views/projects/project_members/_new_project_member.html.haml b/app/views/shared/members/_invite_member.html.haml index 149b0d6cddd..d3a1c85e285 100644 --- a/app/views/projects/project_members/_new_project_member.html.haml +++ b/app/views/shared/members/_invite_member.html.haml @@ -1,13 +1,18 @@ +- access_levels = local_assigns[:access_levels] +- default_access_level = local_assigns[:default_access_level] +- submit_url = local_assigns[:submit_url] +- can_import_members = local_assigns[:can_import_members?] +- import_path = local_assigns[:import_path] .row .col-sm-12 - = form_for @project_member, as: :project_member, url: project_project_members_path(@project), html: { class: 'users-project-form' } do |f| + = form_tag submit_url, class: 'invite-users-form', method: :post do .form-group = label_tag :user_ids, _("GitLab member or Email address"), class: "label-bold" - = users_select_tag(:user_ids, multiple: true, class: "input-clamp qa-member-select-input", scope: :all, email_user: true, placeholder: "Search for members to update or invite") + = users_select_tag(:user_ids, multiple: true, class: 'input-clamp qa-member-select-field', scope: :all, email_user: true, placeholder: 'Search for members to update or invite') .form-group = label_tag :access_level, _("Choose a role permission"), class: "label-bold" .select-wrapper - = select_tag :access_level, options_for_select(ProjectMember.access_level_roles, @project_member.access_level), class: "form-control project-access-select select-control" + = select_tag :access_level, options_for_select(access_levels, default_access_level), class: "form-control project-access-select select-control" = icon('chevron-down') .form-text.text-muted.append-bottom-10 - permissions_docs_path = help_page_path('user/permissions') @@ -18,6 +23,6 @@ = label_tag :expires_at, _('Access expiration date'), class: 'label-bold' = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Expiration date' %i.clear-icon.js-clear-input - = f.submit _("Add to project"), class: "btn btn-success qa-add-member-button" - - if can_import_members? - = link_to _("Import"), import_project_project_members_path(@project), class: "btn btn-default", title: _("Import members from another project") + = submit_tag _("Invite"), class: "btn btn-success", data: { qa_selector: 'invite_member_button' } + - if can_import_members + = link_to _("Import"), import_path, class: "btn btn-default", title: _("Import members from another project") diff --git a/app/views/shared/plugins/_index.html.haml b/app/views/shared/plugins/_index.html.haml deleted file mode 100644 index 9d230d12be2..00000000000 --- a/app/views/shared/plugins/_index.html.haml +++ /dev/null @@ -1,23 +0,0 @@ -- plugins = Gitlab::Plugin.files - -.row.prepend-top-default - .col-lg-4 - %h4.prepend-top-0 - Plugins - %p - #{link_to 'Plugins', help_page_path('administration/plugins')} are similar to - system hooks but are executed as files instead of sending data to a URL. - - .col-lg-8.append-bottom-default - - if plugins.any? - .card - .card-header - Plugins (#{plugins.count}) - %ul.content-list - - plugins.each do |file| - %li - .monospace - = File.basename(file) - - else - .card.bg-light.text-center - .nothing-here-block No plugins found. diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml index fab7ee9d763..c0c009f2a86 100644 --- a/app/views/shared/projects/_list.html.haml +++ b/app/views/shared/projects/_list.html.haml @@ -6,7 +6,6 @@ - merge_requests = true unless local_assigns[:merge_requests] == false - issues = true unless local_assigns[:issues] == false - pipeline_status = true unless local_assigns[:pipeline_status] == false -- ci = false unless local_assigns[:ci] == true - skip_namespace = false unless local_assigns[:skip_namespace] == true - user = local_assigns[:user] - show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true @@ -40,7 +39,7 @@ - projects.each_with_index do |project, i| - css_class = (i >= projects_limit) || project.pending_delete? ? 'hide' : nil = render "shared/projects/project", project: project, skip_namespace: skip_namespace, - avatar: avatar, stars: stars, css_class: css_class, ci: ci, use_creator_avatar: use_creator_avatar, + avatar: avatar, stars: stars, css_class: css_class, use_creator_avatar: use_creator_avatar, forks: forks, show_last_commit_as_description: show_last_commit_as_description, user: user, merge_requests: merge_requests, issues: issues, pipeline_status: pipeline_status, compact_mode: compact_mode = paginate_collection(projects, remote: remote) unless skip_pagination diff --git a/app/views/shared/snippets/_embed.html.haml b/app/views/shared/snippets/_embed.html.haml index d2e35511b32..b401820daf6 100644 --- a/app/views/shared/snippets/_embed.html.haml +++ b/app/views/shared/snippets/_embed.html.haml @@ -10,10 +10,8 @@ %small = number_to_human_size(blob.raw_size) - %a.gitlab-logo{ href: url_for(only_path: false, overwrite_params: nil), title: 'view on gitlab' } - on - %span.logo-text - GitLab + %a.gitlab-logo-wrapper{ href: url_for(only_path: false, overwrite_params: nil), title: 'view on gitlab' } + %img.gitlab-logo{ src: image_url('ext_snippet_icons/logo.svg'), alt: "GitLab logo" } .file-actions.d-none.d-sm-block .btn-group{ role: "group" }< diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 02acf360afc..62b37f52cce 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -10,6 +10,7 @@ - chaos:chaos_sleep - cronjob:admin_email +- cronjob:container_expiration_policy - cronjob:expire_build_artifacts - cronjob:gitlab_usage_ping - cronjob:import_export_project_cleanup @@ -103,6 +104,7 @@ - pipeline_processing:stage_update - pipeline_processing:update_head_pipeline_for_merge_request - pipeline_processing:ci_build_schedule +- pipeline_processing:ci_resource_groups_assign_resource_from_resource_group - deployment:deployments_success - deployment:deployments_finished @@ -156,7 +158,7 @@ - pages - pages_domain_verification - pages_domain_ssl_renewal -- plugin +- file_hook - post_receive - process_commit - project_cache @@ -186,3 +188,5 @@ - project_daily_statistics - create_evidence - group_export +- self_monitoring_project_create +- self_monitoring_project_delete diff --git a/app/workers/chat_notification_worker.rb b/app/workers/chat_notification_worker.rb index 42a23cd472a..6162dcf9d38 100644 --- a/app/workers/chat_notification_worker.rb +++ b/app/workers/chat_notification_worker.rb @@ -3,6 +3,9 @@ class ChatNotificationWorker include ApplicationWorker + TimeoutExceeded = Class.new(StandardError) + + sidekiq_options retry: false feature_category :chatops latency_sensitive_worker! # TODO: break this into multiple jobs @@ -11,18 +14,21 @@ class ChatNotificationWorker # worker_has_external_dependencies! RESCHEDULE_INTERVAL = 2.seconds + RESCHEDULE_TIMEOUT = 5.minutes # rubocop: disable CodeReuse/ActiveRecord - def perform(build_id) + def perform(build_id, reschedule_count = 0) Ci::Build.find_by(id: build_id).try do |build| send_response(build) end rescue Gitlab::Chat::Output::MissingBuildSectionError + raise TimeoutExceeded if timeout_exceeded?(reschedule_count) + # The creation of traces and sections appears to be eventually consistent. # As a result it's possible for us to run the above code before the trace # sections are present. To better handle such cases we'll just reschedule # the job instead of producing an error. - self.class.perform_in(RESCHEDULE_INTERVAL, build_id) + self.class.perform_in(RESCHEDULE_INTERVAL, build_id, reschedule_count + 1) end # rubocop: enable CodeReuse/ActiveRecord @@ -37,4 +43,10 @@ class ChatNotificationWorker end end end + + private + + def timeout_exceeded?(reschedule_count) + (reschedule_count * RESCHEDULE_INTERVAL) >= RESCHEDULE_TIMEOUT + end end diff --git a/app/workers/ci/resource_groups/assign_resource_from_resource_group_worker.rb b/app/workers/ci/resource_groups/assign_resource_from_resource_group_worker.rb new file mode 100644 index 00000000000..62233d19516 --- /dev/null +++ b/app/workers/ci/resource_groups/assign_resource_from_resource_group_worker.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Ci + module ResourceGroups + class AssignResourceFromResourceGroupWorker + include ApplicationWorker + include PipelineQueue + + queue_namespace :pipeline_processing + feature_category :continuous_delivery + + def perform(resource_group_id) + ::Ci::ResourceGroup.find_by_id(resource_group_id).try do |resource_group| + Ci::ResourceGroups::AssignResourceFromResourceGroupService.new(resource_group.project, nil) + .execute(resource_group) + end + end + end + end +end diff --git a/app/workers/concerns/cluster_queue.rb b/app/workers/concerns/cluster_queue.rb index 180b86b0124..60ba8785347 100644 --- a/app/workers/concerns/cluster_queue.rb +++ b/app/workers/concerns/cluster_queue.rb @@ -8,6 +8,6 @@ module ClusterQueue included do queue_namespace :gcp_cluster - feature_category :kubernetes_configuration + feature_category :kubernetes_management end end diff --git a/app/workers/concerns/reenqueuer.rb b/app/workers/concerns/reenqueuer.rb new file mode 100644 index 00000000000..5cc13e490d8 --- /dev/null +++ b/app/workers/concerns/reenqueuer.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +# +# A concern that helps run exactly one instance of a worker, over and over, +# until it returns false or raises. +# +# To ensure the worker is always up, you can schedule it every minute with +# sidekiq-cron. Excess jobs will immediately exit due to an exclusive lease. +# +# The worker must define: +# +# - `#perform` +# - `#lease_timeout` +# +# The worker spec should include `it_behaves_like 'reenqueuer'` and +# `it_behaves_like 'it is rate limited to 1 call per'`. +# +# Optionally override `#minimum_duration` to adjust the rate limit. +# +# When `#perform` returns false, the job will not be reenqueued. Instead, we +# will wait for the next one scheduled by sidekiq-cron. +# +# #lease_timeout should be longer than the longest possible `#perform`. +# The lease is normally released in an ensure block, but it is possible to +# orphan the lease by killing Sidekiq, so it should also be as short as +# possible. Consider that long-running jobs are generally not recommended. +# Ideally, every job finishes within 25 seconds because that is the default +# wait time for graceful termination. +# +# Timing: It runs as often as Sidekiq allows. We rate limit with sleep for +# now: https://gitlab.com/gitlab-org/gitlab/issues/121697 +module Reenqueuer + extend ActiveSupport::Concern + + prepended do + include ExclusiveLeaseGuard + include ReenqueuerSleeper + + sidekiq_options retry: false + end + + def perform(*args) + try_obtain_lease do + reenqueue(*args) do + ensure_minimum_duration(minimum_duration) do + super + end + end + end + end + + private + + def reenqueue(*args) + self.class.perform_async(*args) if yield + end + + # Override as needed + def minimum_duration + 5.seconds + end + + # We intend to get rid of sleep: + # https://gitlab.com/gitlab-org/gitlab/issues/121697 + module ReenqueuerSleeper + # The block will run, and then sleep until the minimum duration. Returns the + # block's return value. + # + # Usage: + # + # ensure_minimum_duration(5.seconds) do + # # do something + # end + # + def ensure_minimum_duration(minimum_duration) + start_time = Time.now + + result = yield + + sleep_if_time_left(minimum_duration, start_time) + + result + end + + private + + def sleep_if_time_left(minimum_duration, start_time) + time_left = calculate_time_left(minimum_duration, start_time) + + sleep(time_left) if time_left > 0 + end + + def calculate_time_left(minimum_duration, start_time) + minimum_duration - elapsed_time(start_time) + end + + def elapsed_time(start_time) + Time.now - start_time + end + end +end diff --git a/app/workers/concerns/self_monitoring_project_worker.rb b/app/workers/concerns/self_monitoring_project_worker.rb new file mode 100644 index 00000000000..44dd6866fad --- /dev/null +++ b/app/workers/concerns/self_monitoring_project_worker.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module SelfMonitoringProjectWorker + extend ActiveSupport::Concern + + included do + # This worker falls under Self-monitoring with Monitor::APM group. However, + # self-monitoring is not classified as a feature category but rather as + # Other Functionality. Metrics seems to be the closest feature_category for + # this worker. + feature_category :metrics + end + + LEASE_TIMEOUT = 15.minutes.to_i + EXCLUSIVE_LEASE_KEY = 'self_monitoring_service_creation_deletion' + + class_methods do + # @param job_id [String] + # Job ID that is used to construct the cache keys. + # @return [Hash] + # Returns true if the job is enqueued or in progress and false otherwise. + def in_progress?(job_id) + Gitlab::SidekiqStatus.job_status(Array.wrap(job_id)).first + end + end + + private + + def lease_key + EXCLUSIVE_LEASE_KEY + end + + def lease_timeout + self.class::LEASE_TIMEOUT + end +end diff --git a/app/models/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb index 506215ca9ed..506215ca9ed 100644 --- a/app/models/concerns/worker_attributes.rb +++ b/app/workers/concerns/worker_attributes.rb diff --git a/app/workers/container_expiration_policy_worker.rb b/app/workers/container_expiration_policy_worker.rb new file mode 100644 index 00000000000..595208230f6 --- /dev/null +++ b/app/workers/container_expiration_policy_worker.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class ContainerExpirationPolicyWorker + include ApplicationWorker + include CronjobQueue + + feature_category :container_registry + + def perform + ContainerExpirationPolicy.runnable_schedules.preloaded.find_each do |container_expiration_policy| + ContainerExpirationPolicyService.new( + container_expiration_policy.project, container_expiration_policy.project.owner + ).execute(container_expiration_policy) + end + end +end diff --git a/app/workers/deployments/finished_worker.rb b/app/workers/deployments/finished_worker.rb index 90bbc193651..6196b032f63 100644 --- a/app/workers/deployments/finished_worker.rb +++ b/app/workers/deployments/finished_worker.rb @@ -9,7 +9,10 @@ module Deployments worker_resource_boundary :cpu def perform(deployment_id) - Deployment.find_by_id(deployment_id).try(:execute_hooks) + if (deploy = Deployment.find_by_id(deployment_id)) + LinkMergeRequestsService.new(deploy).execute + deploy.execute_hooks + end end end end diff --git a/app/workers/plugin_worker.rb b/app/workers/file_hook_worker.rb index e708031abdf..24fc2d75d24 100644 --- a/app/workers/plugin_worker.rb +++ b/app/workers/file_hook_worker.rb @@ -1,16 +1,16 @@ # frozen_string_literal: true -class PluginWorker +class FileHookWorker include ApplicationWorker sidekiq_options retry: false feature_category :integrations def perform(file_name, data) - success, message = Gitlab::Plugin.execute(file_name, data) + success, message = Gitlab::FileHook.execute(file_name, data) unless success - Gitlab::PluginLogger.error("Plugin Error => #{file_name}: #{message}") + Gitlab::FileHookLogger.error("File Hook Error => #{file_name}: #{message}") end true diff --git a/app/workers/group_destroy_worker.rb b/app/workers/group_destroy_worker.rb index 553fd359baf..fc751f8b612 100644 --- a/app/workers/group_destroy_worker.rb +++ b/app/workers/group_destroy_worker.rb @@ -4,7 +4,7 @@ class GroupDestroyWorker include ApplicationWorker include ExceptionBacktrace - feature_category :groups + feature_category :subgroups def perform(group_id, user_id) begin diff --git a/app/workers/pipeline_update_worker.rb b/app/workers/pipeline_update_worker.rb index 5b742461f7a..0321ea5a6ce 100644 --- a/app/workers/pipeline_update_worker.rb +++ b/app/workers/pipeline_update_worker.rb @@ -7,10 +7,7 @@ class PipelineUpdateWorker queue_namespace :pipeline_processing latency_sensitive_worker! - # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) - Ci::Pipeline.find_by(id: pipeline_id) - .try(:update_status) + Ci::Pipeline.find_by_id(pipeline_id)&.update_legacy_status end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/rebase_worker.rb b/app/workers/rebase_worker.rb index 7343226fdcd..fd182125c07 100644 --- a/app/workers/rebase_worker.rb +++ b/app/workers/rebase_worker.rb @@ -7,12 +7,12 @@ class RebaseWorker feature_category :source_code_management - def perform(merge_request_id, current_user_id) + def perform(merge_request_id, current_user_id, skip_ci = false) current_user = User.find(current_user_id) merge_request = MergeRequest.find(merge_request_id) MergeRequests::RebaseService .new(merge_request.source_project, current_user) - .execute(merge_request) + .execute(merge_request, skip_ci: skip_ci) end end diff --git a/app/workers/self_monitoring_project_create_worker.rb b/app/workers/self_monitoring_project_create_worker.rb new file mode 100644 index 00000000000..429ac8aacc4 --- /dev/null +++ b/app/workers/self_monitoring_project_create_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class SelfMonitoringProjectCreateWorker + include ApplicationWorker + include ExclusiveLeaseGuard + include SelfMonitoringProjectWorker + + def perform + try_obtain_lease do + Gitlab::DatabaseImporters::SelfMonitoring::Project::CreateService.new.execute + end + end +end diff --git a/app/workers/self_monitoring_project_delete_worker.rb b/app/workers/self_monitoring_project_delete_worker.rb new file mode 100644 index 00000000000..07a7d3f6c45 --- /dev/null +++ b/app/workers/self_monitoring_project_delete_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class SelfMonitoringProjectDeleteWorker + include ApplicationWorker + include ExclusiveLeaseGuard + include SelfMonitoringProjectWorker + + def perform + try_obtain_lease do + Gitlab::DatabaseImporters::SelfMonitoring::Project::DeleteService.new.execute + end + end +end diff --git a/app/workers/stage_update_worker.rb b/app/workers/stage_update_worker.rb index de2454128f6..a96c4c6dda2 100644 --- a/app/workers/stage_update_worker.rb +++ b/app/workers/stage_update_worker.rb @@ -7,11 +7,7 @@ class StageUpdateWorker queue_namespace :pipeline_processing latency_sensitive_worker! - # rubocop: disable CodeReuse/ActiveRecord def perform(stage_id) - Ci::Stage.find_by(id: stage_id).try do |stage| - stage.update_status - end + Ci::Stage.find_by_id(stage_id)&.update_legacy_status end - # rubocop: enable CodeReuse/ActiveRecord end |