diff options
Diffstat (limited to 'app')
59 files changed, 864 insertions, 369 deletions
diff --git a/app/assets/javascripts/code_navigation/components/doc_line.vue b/app/assets/javascripts/code_navigation/components/doc_line.vue index 69d398893d9..4d44c984833 100644 --- a/app/assets/javascripts/code_navigation/components/doc_line.vue +++ b/app/assets/javascripts/code_navigation/components/doc_line.vue @@ -18,5 +18,6 @@ export default { <span v-for="(token, tokenIndex) in tokens" :key="tokenIndex" :class="token.class">{{ token.value }}</span> + <br /> </span> </template> diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue index ec6025c84bb..298771a4d12 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue @@ -170,7 +170,7 @@ export default { }, availableGroupsForImport() { - return this.groupsTableData.filter((g) => g.flags.isAvailableForImport && g.flags.isInvalid); + return this.groupsTableData.filter((g) => g.flags.isAvailableForImport && !g.flags.isInvalid); }, humanizedTotal() { @@ -521,13 +521,15 @@ export default { /> <template v-else> <div - class="gl-bg-gray-10 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-p-4 gl-display-flex gl-align-items-center" + class="gl-bg-gray-10 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-px-4 gl-display-flex gl-align-items-center import-table-bar" > - <gl-sprintf :message="__('%{count} selected')"> - <template #count> - {{ selectedGroupsIds.length }} - </template> - </gl-sprintf> + <span data-test-id="selection-count"> + <gl-sprintf :message="__('%{count} selected')"> + <template #count> + {{ selectedGroupsIds.length }} + </template> + </gl-sprintf> + </span> <gl-button category="primary" variant="confirm" @@ -539,7 +541,7 @@ export default { </div> <gl-table ref="table" - class="gl-w-full" + class="gl-w-full import-table" data-qa-selector="import_table" :tbody-tr-class="rowClasses" :tbody-tr-attr="qaRowAttributes" diff --git a/app/assets/javascripts/jobs/bridge/app.vue b/app/assets/javascripts/jobs/bridge/app.vue new file mode 100644 index 00000000000..67c22712776 --- /dev/null +++ b/app/assets/javascripts/jobs/bridge/app.vue @@ -0,0 +1,20 @@ +<script> +import BridgeEmptyState from './components/empty_state.vue'; +import BridgeSidebar from './components/sidebar.vue'; + +export default { + name: 'BridgePageApp', + components: { + BridgeEmptyState, + BridgeSidebar, + }, +}; +</script> +<template> + <div> + <!-- TODO: get job details and show CI header --> + <!-- TODO: add downstream pipeline path --> + <bridge-empty-state downstream-pipeline-path="#" /> + <bridge-sidebar /> + </div> +</template> diff --git a/app/assets/javascripts/jobs/bridge/components/constants.js b/app/assets/javascripts/jobs/bridge/components/constants.js new file mode 100644 index 00000000000..33310b3157a --- /dev/null +++ b/app/assets/javascripts/jobs/bridge/components/constants.js @@ -0,0 +1 @@ +export const SIDEBAR_COLLAPSE_BREAKPOINTS = ['xs', 'sm']; diff --git a/app/assets/javascripts/jobs/bridge/components/empty_state.vue b/app/assets/javascripts/jobs/bridge/components/empty_state.vue new file mode 100644 index 00000000000..bd07d863719 --- /dev/null +++ b/app/assets/javascripts/jobs/bridge/components/empty_state.vue @@ -0,0 +1,45 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + name: 'BridgeEmptyState', + i18n: { + title: __('This job triggers a downstream pipeline'), + linkBtnText: __('View downstream pipeline'), + }, + components: { + GlButton, + }, + inject: { + emptyStateIllustrationPath: { + type: String, + require: true, + }, + }, + props: { + downstreamPipelinePath: { + type: String, + required: false, + default: undefined, + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11"> + <img :src="emptyStateIllustrationPath" /> + <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1> + <gl-button + v-if="downstreamPipelinePath" + class="gl-mt-3" + category="secondary" + variant="confirm" + size="medium" + :href="downstreamPipelinePath" + > + {{ $options.i18n.linkBtnText }} + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/jobs/bridge/components/sidebar.vue b/app/assets/javascripts/jobs/bridge/components/sidebar.vue new file mode 100644 index 00000000000..68b767408f0 --- /dev/null +++ b/app/assets/javascripts/jobs/bridge/components/sidebar.vue @@ -0,0 +1,98 @@ +<script> +import { GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; +import { __ } from '~/locale'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; +import { JOB_SIDEBAR } from '../../constants'; +import { SIDEBAR_COLLAPSE_BREAKPOINTS } from './constants'; + +export default { + styles: { + top: '75px', + width: '290px', + }, + name: 'BridgeSidebar', + i18n: { + ...JOB_SIDEBAR, + retryButton: __('Retry'), + retryTriggerJob: __('Retry the trigger job'), + retryDownstreamPipeline: __('Retry the downstream pipeline'), + }, + borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'], + components: { + GlButton, + GlDropdown, + GlDropdownItem, + TooltipOnTruncate, + }, + inject: { + buildName: { + type: String, + default: '', + }, + }, + data() { + return { + isSidebarExpanded: true, + }; + }, + created() { + window.addEventListener('resize', this.onResize); + }, + mounted() { + this.onResize(); + }, + methods: { + toggleSidebar() { + this.isSidebarExpanded = !this.isSidebarExpanded; + }, + onResize() { + const breakpoint = bp.getBreakpointSize(); + if (SIDEBAR_COLLAPSE_BREAKPOINTS.includes(breakpoint)) { + this.isSidebarExpanded = false; + } else if (!this.isSidebarExpanded) { + this.isSidebarExpanded = true; + } + }, + }, +}; +</script> +<template> + <aside + class="gl-fixed gl-right-0 gl-px-5 gl-bg-gray-10 gl-h-full gl-border-l-solid gl-border-1 gl-border-gray-100 gl-z-index-200 gl-overflow-hidden" + :style="this.$options.styles" + :class="{ + 'gl-display-none': !isSidebarExpanded, + }" + > + <div class="gl-py-5 gl-display-flex gl-align-items-center"> + <tooltip-on-truncate :title="buildName" truncate-target="child" + ><h4 class="gl-mb-0 gl-mr-2 gl-text-truncate"> + {{ buildName }} + </h4> + </tooltip-on-truncate> + <!-- TODO: implement retry actions --> + <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right"> + <gl-dropdown + :text="$options.i18n.retryButton" + category="primary" + variant="confirm" + right + size="medium" + > + <gl-dropdown-item>{{ $options.i18n.retryTriggerJob }}</gl-dropdown-item> + <gl-dropdown-item>{{ $options.i18n.retryDownstreamPipeline }}</gl-dropdown-item> + </gl-dropdown> + </div> + <gl-button + :aria-label="$options.i18n.toggleSidebar" + data-testid="sidebar-expansion-toggle" + category="tertiary" + class="gl-md-display-none gl-ml-2" + icon="chevron-double-lg-right" + @click="toggleSidebar" + /> + </div> + <!-- TODO: get job details and show commit block, stage dropdown, jobs list --> + </aside> +</template> diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue index 6105299e15c..97141a27a5e 100644 --- a/app/assets/javascripts/jobs/components/job_log_controllers.vue +++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue @@ -5,7 +5,7 @@ import { __, s__, sprintf } from '~/locale'; export default { i18n: { - eraseLogButtonLabel: s__('Job|Erase job log'), + eraseLogButtonLabel: s__('Job|Erase job log and artifacts'), scrollToBottomButtonLabel: s__('Job|Scroll to bottom'), scrollToTopButtonLabel: s__('Job|Scroll to top'), showRawButtonLabel: s__('Job|Show complete raw'), diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js index 1fb6a6f9850..e078a6c2319 100644 --- a/app/assets/javascripts/jobs/index.js +++ b/app/assets/javascripts/jobs/index.js @@ -1,10 +1,11 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import BridgeApp from './bridge/app.vue'; import JobApp from './components/job_app.vue'; import createStore from './store'; -export default () => { - const element = document.getElementById('js-job-vue-app'); - +const initializeJobPage = (element) => { const store = createStore(); // Let's start initializing the store (i.e. fetching data) right away @@ -51,3 +52,35 @@ export default () => { }, }); }; + +const initializeBridgePage = (el) => { + const { buildName, emptyStateIllustrationPath } = el.dataset; + + Vue.use(VueApollo); + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el, + apolloProvider, + provide: { + buildName, + emptyStateIllustrationPath, + }, + render(h) { + return h(BridgeApp); + }, + }); +}; + +export default () => { + const jobElement = document.getElementById('js-job-page'); + const bridgeElement = document.getElementById('js-bridge-page'); + + if (jobElement) { + initializeJobPage(jobElement); + } else { + initializeBridgePage(bridgeElement); + } +}; diff --git a/app/assets/javascripts/milestones/milestone.js b/app/assets/javascripts/milestones/milestone.js index 2c43bed412e..05102f73f92 100644 --- a/app/assets/javascripts/milestones/milestone.js +++ b/app/assets/javascripts/milestones/milestone.js @@ -1,43 +1,43 @@ -import $ from 'jquery'; import createFlash from '~/flash'; +import { sanitize } from '~/lib/dompurify'; import axios from '~/lib/utils/axios_utils'; +import { historyPushState } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; +import { GlTabsBehavior, TAB_SHOWN_EVENT } from '~/tabs'; export default class Milestone { constructor() { + this.tabsEl = document.querySelector('.js-milestone-tabs'); + this.glTabs = new GlTabsBehavior(this.tabsEl); + this.loadedTabs = new WeakSet(); + this.bindTabsSwitching(); this.loadInitialTab(); } bindTabsSwitching() { - return $('a[data-toggle="tab"]').on('show.bs.tab', (e) => { - const $target = $(e.target); - - window.location.hash = $target.attr('href'); - this.loadTab($target); + this.tabsEl.addEventListener(TAB_SHOWN_EVENT, (event) => { + const tab = event.target; + const { activeTabPanel } = event.detail; + historyPushState(tab.getAttribute('href')); + this.loadTab(tab, activeTabPanel); }); } loadInitialTab() { - const $target = $(`.js-milestone-tabs a:not(.active)[href="${window.location.hash}"]`); - - if ($target.length) { - $target.tab('show'); - } else { - this.loadTab($('.js-milestone-tabs a.active')); - } + const tab = this.tabsEl.querySelector(`a[href="${window.location.hash}"]`); + this.glTabs.activateTab(tab || this.glTabs.activeTab); } - // eslint-disable-next-line class-methods-use-this - loadTab($target) { - const endpoint = $target.data('endpoint'); - const tabElId = $target.attr('href'); + loadTab(tab, tabPanel) { + const { endpoint } = tab.dataset; - if (endpoint && !$target.hasClass('is-loaded')) { + if (endpoint && !this.loadedTabs.has(tab)) { axios .get(endpoint) .then(({ data }) => { - $(tabElId).html(data.html); - $target.addClass('is-loaded'); + // eslint-disable-next-line no-param-reassign + tabPanel.innerHTML = sanitize(data.html); + this.loadedTabs.add(tab); }) .catch(() => createFlash({ diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue index 52c63a1355a..eb112238c11 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue @@ -4,7 +4,6 @@ import { GlEmptyState, GlFormGroup, GlFormInputGroup, - GlLink, GlSkeletonLoader, GlSprintf, } from '@gitlab/ui'; @@ -16,10 +15,7 @@ import { DEPENDENCY_PROXY_SETTINGS_DESCRIPTION, DEPENDENCY_PROXY_DOCS_PATH, } from '~/packages_and_registries/settings/group/constants'; -import { - GRAPHQL_PAGE_SIZE, - ENABLE_DEPENDENCY_PROXY_DOCS_PATH, -} from '~/packages_and_registries/dependency_proxy/constants'; +import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/dependency_proxy/constants'; import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql'; @@ -29,7 +25,6 @@ export default { GlEmptyState, GlFormGroup, GlFormInputGroup, - GlLink, GlSkeletonLoader, GlSprintf, ClipboardButton, @@ -41,9 +36,6 @@ export default { proxyNotAvailableText: s__( 'DependencyProxy|Dependency Proxy feature is limited to public groups for now.', ), - proxyDisabledText: s__( - 'DependencyProxy|The Dependency Proxy is disabled. %{docLinkStart}Learn how to enable it%{docLinkEnd}.', - ), proxyImagePrefix: s__('DependencyProxy|Dependency Proxy image prefix'), copyImagePrefixText: s__('DependencyProxy|Copy prefix'), blobCountAndSize: s__('DependencyProxy|Contains %{count} blobs of images (%{size})'), @@ -52,7 +44,6 @@ export default { }, links: { DEPENDENCY_PROXY_DOCS_PATH, - ENABLE_DEPENDENCY_PROXY_DOCS_PATH, }, data() { return { @@ -79,9 +70,7 @@ export default { }, ]; }, - dependencyProxyEnabled() { - return this.group?.dependencyProxySetting?.enabled; - }, + queryVariables() { return { fullPath: this.groupPath, first: GRAPHQL_PAGE_SIZE }; }, @@ -131,7 +120,7 @@ export default { <gl-skeleton-loader v-else-if="$apollo.queries.group.loading" /> - <div v-else-if="dependencyProxyEnabled" data-testid="main-area"> + <div v-else data-testid="main-area"> <gl-form-group :label="$options.i18n.proxyImagePrefix"> <gl-form-input-group readonly @@ -170,12 +159,5 @@ export default { :title="$options.i18n.noManifestTitle" /> </div> - <gl-alert v-else :dismissible="false" data-testid="proxy-disabled"> - <gl-sprintf :message="$options.i18n.proxyDisabledText"> - <template #docLink="{ content }"> - <gl-link :href="$options.links.ENABLE_DEPENDENCY_PROXY_DOCS_PATH">{{ content }}</gl-link> - </template> - </gl-sprintf> - </gl-alert> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js index ab1e3adcb55..3c6ede6fdce 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js @@ -1,7 +1 @@ -import { helpPagePath } from '~/helpers/help_page_helper'; - export const GRAPHQL_PAGE_SIZE = 20; -export const ENABLE_DEPENDENCY_PROXY_DOCS_PATH = helpPagePath( - 'user/packages/dependency_proxy/index', - { anchor: 'enable-or-disable-the-dependency-proxy-for-a-group' }, -); diff --git a/app/assets/javascripts/runner/components/runner_status_badge.vue b/app/assets/javascripts/runner/components/runner_status_badge.vue index 24ca6aacfc9..0823876a187 100644 --- a/app/assets/javascripts/runner/components/runner_status_badge.vue +++ b/app/assets/javascripts/runner/components/runner_status_badge.vue @@ -9,6 +9,7 @@ import { I18N_STALE_RUNNER_DESCRIPTION, STATUS_ONLINE, STATUS_NOT_CONNECTED, + STATUS_NEVER_CONTACTED, STATUS_OFFLINE, STATUS_STALE, } from '../constants'; @@ -45,6 +46,7 @@ export default { }), }; case STATUS_NOT_CONNECTED: + case STATUS_NEVER_CONTACTED: return { variant: 'muted', label: s__('Runners|not connected'), diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js index 68e45fcf8e9..355f3054917 100644 --- a/app/assets/javascripts/runner/constants.js +++ b/app/assets/javascripts/runner/constants.js @@ -61,6 +61,7 @@ export const STATUS_PAUSED = 'PAUSED'; export const STATUS_ONLINE = 'ONLINE'; export const STATUS_NOT_CONNECTED = 'NOT_CONNECTED'; +export const STATUS_NEVER_CONTACTED = 'NEVER_CONTACTED'; export const STATUS_OFFLINE = 'OFFLINE'; export const STATUS_STALE = 'STALE'; diff --git a/app/assets/javascripts/tabs/constants.js b/app/assets/javascripts/tabs/constants.js new file mode 100644 index 00000000000..3b84d7394d4 --- /dev/null +++ b/app/assets/javascripts/tabs/constants.js @@ -0,0 +1,20 @@ +export const ACTIVE_TAB_CLASSES = Object.freeze([ + 'active', + 'gl-tab-nav-item-active', + 'gl-tab-nav-item-active-indigo', +]); + +export const ACTIVE_PANEL_CLASS = 'active'; + +export const KEY_CODE_LEFT = 'ArrowLeft'; +export const KEY_CODE_UP = 'ArrowUp'; +export const KEY_CODE_RIGHT = 'ArrowRight'; +export const KEY_CODE_DOWN = 'ArrowDown'; + +export const ATTR_ARIA_CONTROLS = 'aria-controls'; +export const ATTR_ARIA_LABELLEDBY = 'aria-labelledby'; +export const ATTR_ARIA_SELECTED = 'aria-selected'; +export const ATTR_ROLE = 'role'; +export const ATTR_TABINDEX = 'tabindex'; + +export const TAB_SHOWN_EVENT = 'gl-tab-shown'; diff --git a/app/assets/javascripts/tabs/index.js b/app/assets/javascripts/tabs/index.js new file mode 100644 index 00000000000..44937e593e0 --- /dev/null +++ b/app/assets/javascripts/tabs/index.js @@ -0,0 +1,239 @@ +import { uniqueId } from 'lodash'; +import { + ACTIVE_TAB_CLASSES, + ATTR_ROLE, + ATTR_ARIA_CONTROLS, + ATTR_TABINDEX, + ATTR_ARIA_SELECTED, + ATTR_ARIA_LABELLEDBY, + ACTIVE_PANEL_CLASS, + KEY_CODE_LEFT, + KEY_CODE_UP, + KEY_CODE_RIGHT, + KEY_CODE_DOWN, + TAB_SHOWN_EVENT, +} from './constants'; + +export { TAB_SHOWN_EVENT }; + +/** + * The `GlTabsBehavior` class adds interactivity to tabs created by the `gl_tabs_nav` and + * `gl_tab_link_to` Rails helpers. + * + * Example using `href` references: + * + * ```haml + * = gl_tabs_nav({ class: 'js-my-tabs' }) do + * = gl_tab_link_to '#foo', item_active: true do + * = _('Foo') + * = gl_tab_link_to '#bar' do + * = _('Bar') + * + * .tab-content + * .tab-pane.active#foo + * .tab-pane#bar + * ``` + * + * ```javascript + * import { GlTabsBehavior } from '~/tabs'; + * + * const glTabs = new GlTabsBehavior(document.querySelector('.js-my-tabs')); + * ``` + * + * Example using `aria-controls` references: + * + * ```haml + * = gl_tabs_nav({ class: 'js-my-tabs' }) do + * = gl_tab_link_to '#', item_active: true, 'aria-controls': 'foo' do + * = _('Foo') + * = gl_tab_link_to '#', 'aria-controls': 'bar' do + * = _('Bar') + * + * .tab-content + * .tab-pane.active#foo + * .tab-pane#bar + * ``` + * + * ```javascript + * import { GlTabsBehavior } from '~/tabs'; + * + * const glTabs = new GlTabsBehavior(document.querySelector('.js-my-tabs')); + * ``` + * + * `GlTabsBehavior` can be used to replace Bootstrap tab implementations that cannot + * easily be rewritten in Vue. + * + * NOTE: Do *not* use `GlTabsBehavior` with markup generated by other means, as it may not + * work correctly. + * + * Tab panels must exist somewhere in the page for the tabs to control. Tab panels + * must: + * - be immediate children of a `.tab-content` element + * - have the `tab-pane` class + * - if the panel is active, have the `active` class + * - have a unique `id` attribute + * + * In order to associate tabs with panels, the tabs must reference their panel's + * `id` by having one of the following attributes: + * - `href`, e.g., `href="#the-panel-id"` (note the leading `#` in the value) + * - `aria-controls`, e.g., `aria-controls="the-panel-id"` (no leading `#`) + * + * Exactly one tab/panel must be active in the original markup. + * + * Call the `destroy` method on an instance to remove event listeners that were + * added during construction. Other DOM mutations (like ARIA attributes) are + * _not_ reverted. + */ +export class GlTabsBehavior { + /** + * Create a GlTabsBehavior instance. + * + * @param {HTMLElement} el The element created by the Rails `gl_tabs_nav` helper. + */ + constructor(el) { + if (!el) { + throw new Error('Cannot instantiate GlTabsBehavior without an element'); + } + + this.destroyFns = []; + this.tabList = el; + this.tabs = this.getTabs(); + this.activeTab = null; + + this.setAccessibilityAttrs(); + this.bindEvents(); + } + + setAccessibilityAttrs() { + this.tabList.setAttribute(ATTR_ROLE, 'tablist'); + this.tabs.forEach((tab) => { + if (!tab.hasAttribute('id')) { + tab.setAttribute('id', uniqueId('gl_tab_nav__tab_')); + } + + if (!this.activeTab && tab.classList.contains(ACTIVE_TAB_CLASSES[0])) { + this.activeTab = tab; + tab.setAttribute(ATTR_ARIA_SELECTED, 'true'); + tab.removeAttribute(ATTR_TABINDEX); + } else { + tab.setAttribute(ATTR_ARIA_SELECTED, 'false'); + tab.setAttribute(ATTR_TABINDEX, '-1'); + } + + tab.setAttribute(ATTR_ROLE, 'tab'); + tab.closest('.nav-item').setAttribute(ATTR_ROLE, 'presentation'); + + const tabPanel = this.getPanelForTab(tab); + if (!tab.hasAttribute(ATTR_ARIA_CONTROLS)) { + tab.setAttribute(ATTR_ARIA_CONTROLS, tabPanel.id); + } + + tabPanel.setAttribute(ATTR_ROLE, 'tabpanel'); + tabPanel.setAttribute(ATTR_ARIA_LABELLEDBY, tab.id); + }); + } + + bindEvents() { + this.tabs.forEach((tab) => { + this.bindEvent(tab, 'click', (event) => { + event.preventDefault(); + + if (tab !== this.activeTab) { + this.activateTab(tab); + } + }); + + this.bindEvent(tab, 'keydown', (event) => { + const { code } = event; + if (code === KEY_CODE_UP || code === KEY_CODE_LEFT) { + event.preventDefault(); + this.activatePreviousTab(); + } else if (code === KEY_CODE_DOWN || code === KEY_CODE_RIGHT) { + event.preventDefault(); + this.activateNextTab(); + } + }); + }); + } + + bindEvent(el, ...args) { + el.addEventListener(...args); + + this.destroyFns.push(() => { + el.removeEventListener(...args); + }); + } + + activatePreviousTab() { + const currentTabIndex = this.tabs.indexOf(this.activeTab); + + if (currentTabIndex <= 0) return; + + const previousTab = this.tabs[currentTabIndex - 1]; + this.activateTab(previousTab); + previousTab.focus(); + } + + activateNextTab() { + const currentTabIndex = this.tabs.indexOf(this.activeTab); + + if (currentTabIndex >= this.tabs.length - 1) return; + + const nextTab = this.tabs[currentTabIndex + 1]; + this.activateTab(nextTab); + nextTab.focus(); + } + + getTabs() { + return Array.from(this.tabList.querySelectorAll('.gl-tab-nav-item')); + } + + // eslint-disable-next-line class-methods-use-this + getPanelForTab(tab) { + const ariaControls = tab.getAttribute(ATTR_ARIA_CONTROLS); + + if (ariaControls) { + return document.querySelector(`#${ariaControls}`); + } + + return document.querySelector(tab.getAttribute('href')); + } + + activateTab(tabToActivate) { + // Deactivate active tab first + this.activeTab.setAttribute(ATTR_ARIA_SELECTED, 'false'); + this.activeTab.setAttribute(ATTR_TABINDEX, '-1'); + this.activeTab.classList.remove(...ACTIVE_TAB_CLASSES); + + const activePanel = this.getPanelForTab(this.activeTab); + activePanel.classList.remove(ACTIVE_PANEL_CLASS); + + // Now activate the given tab/panel + tabToActivate.setAttribute(ATTR_ARIA_SELECTED, 'true'); + tabToActivate.removeAttribute(ATTR_TABINDEX); + tabToActivate.classList.add(...ACTIVE_TAB_CLASSES); + + const tabPanel = this.getPanelForTab(tabToActivate); + tabPanel.classList.add(ACTIVE_PANEL_CLASS); + + this.activeTab = tabToActivate; + + this.dispatchTabShown(tabToActivate, tabPanel); + } + + // eslint-disable-next-line class-methods-use-this + dispatchTabShown(tab, activeTabPanel) { + const event = new CustomEvent(TAB_SHOWN_EVENT, { + bubbles: true, + detail: { + activeTabPanel, + }, + }); + + tab.dispatchEvent(event); + } + + destroy() { + this.destroyFns.forEach((destroy) => destroy()); + } +} diff --git a/app/assets/stylesheets/page_bundles/import.scss b/app/assets/stylesheets/page_bundles/import.scss index c74b5460e1a..79468ce62ce 100644 --- a/app/assets/stylesheets/page_bundles/import.scss +++ b/app/assets/stylesheets/page_bundles/import.scss @@ -1,12 +1,5 @@ @import 'mixins_and_variables_and_functions'; -// Fixing double scrollbar issue -// See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1156 and -// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/54837 -.import-entities-namespace-dropdown.show.dropdown .dropdown-menu { - max-height: initial; -} - .import-jobs-to-col { width: 39%; } @@ -38,3 +31,31 @@ box-shadow: inset 0 0 0 1px var(--gray-200, $gray-200); } } + +$import-bar-height: $gl-spacing-scale-11; + +.import-table-bar { + @include gl-sticky; + height: $import-bar-height; + top: $header-height; + z-index: 3; + + html.with-performance-bar & { + top: $header-height + $performance-bar-height; + } +} + +.import-table { + border-collapse: separate; + + thead { + @include gl-sticky; + background-color: var(--gray-10, $gray-10); + top: calc(#{$header-height} + #{$import-bar-height}); + z-index: 3; + + html.with-performance-bar & { + top: calc(#{$header-height + $performance-bar-height} + #{$import-bar-height}); + } + } +} diff --git a/app/controllers/concerns/dependency_proxy/group_access.rb b/app/controllers/concerns/dependency_proxy/group_access.rb index 07aca72b22f..44611641529 100644 --- a/app/controllers/concerns/dependency_proxy/group_access.rb +++ b/app/controllers/concerns/dependency_proxy/group_access.rb @@ -5,13 +5,13 @@ module DependencyProxy extend ActiveSupport::Concern included do - before_action :verify_dependency_proxy_enabled! + before_action :verify_dependency_proxy_available! before_action :authorize_read_dependency_proxy! end private - def verify_dependency_proxy_enabled! + def verify_dependency_proxy_available! render_404 unless group&.dependency_proxy_feature_available? end diff --git a/app/controllers/groups/dependency_proxies_controller.rb b/app/controllers/groups/dependency_proxies_controller.rb index b037aa52939..2e120de435e 100644 --- a/app/controllers/groups/dependency_proxies_controller.rb +++ b/app/controllers/groups/dependency_proxies_controller.rb @@ -5,30 +5,19 @@ module Groups include ::DependencyProxy::GroupAccess before_action :authorize_admin_dependency_proxy!, only: :update - before_action :dependency_proxy + before_action :verify_dependency_proxy_enabled! feature_category :package_registry - def show - @blobs_count = group.dependency_proxy_blobs.count - @blobs_total_size = group.dependency_proxy_blobs.total_size - end - - def update - dependency_proxy.update(dependency_proxy_params) - - redirect_to group_dependency_proxy_path(group) - end - private def dependency_proxy @dependency_proxy ||= - group.dependency_proxy_setting || group.create_dependency_proxy_setting + group.dependency_proxy_setting || group.create_dependency_proxy_setting! end - def dependency_proxy_params - params.require(:dependency_proxy_group_setting).permit(:enabled) + def verify_dependency_proxy_enabled! + render_404 unless dependency_proxy.enabled? end end end diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 81b8da9cba3..32a192192bd 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -4,8 +4,8 @@ class Projects::JobsController < Projects::ApplicationController include SendFileUpload include ContinueParams - before_action :find_job_as_build, except: [:index, :play] - before_action :find_job_as_processable, only: [:play] + before_action :find_job_as_build, except: [:index, :play, :show] + before_action :find_job_as_processable, only: [:play, :show] before_action :authorize_read_build_trace!, only: [:trace, :raw] before_action :authorize_read_build! before_action :authorize_update_build!, diff --git a/app/controllers/user_callouts_controller.rb b/app/controllers/user_callouts_controller.rb deleted file mode 100644 index f52a09adf5a..00000000000 --- a/app/controllers/user_callouts_controller.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -class UserCalloutsController < ApplicationController - feature_category :navigation - - def create - if callout.persisted? - respond_to do |format| - format.json { head :ok } - end - else - respond_to do |format| - format.json { head :bad_request } - end - end - end - - private - - def callout - Users::DismissUserCalloutService.new( - container: nil, current_user: current_user, params: { feature_name: feature_name } - ).execute - end - - def feature_name - params.require(:feature_name) - end -end diff --git a/app/controllers/users/callouts_controller.rb b/app/controllers/users/callouts_controller.rb new file mode 100644 index 00000000000..fe308d9dd1e --- /dev/null +++ b/app/controllers/users/callouts_controller.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Users + class CalloutsController < ApplicationController + feature_category :navigation + + def create + if callout.persisted? + respond_to do |format| + format.json { head :ok } + end + else + respond_to do |format| + format.json { head :bad_request } + end + end + end + + private + + def callout + Users::DismissCalloutService.new( + container: nil, current_user: current_user, params: { feature_name: feature_name } + ).execute + end + + def feature_name + params.require(:feature_name) + end + end +end diff --git a/app/controllers/users/group_callouts_controller.rb b/app/controllers/users/group_callouts_controller.rb index cc27452e6a3..abca12ccea7 100644 --- a/app/controllers/users/group_callouts_controller.rb +++ b/app/controllers/users/group_callouts_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Users - class GroupCalloutsController < UserCalloutsController + class GroupCalloutsController < Users::CalloutsController private def callout diff --git a/app/graphql/mutations/user_callouts/create.rb b/app/graphql/mutations/user_callouts/create.rb index ff6e5cd28dd..1be99ea0ecd 100644 --- a/app/graphql/mutations/user_callouts/create.rb +++ b/app/graphql/mutations/user_callouts/create.rb @@ -15,7 +15,7 @@ module Mutations description: 'User callout dismissed.' def resolve(feature_name:) - callout = Users::DismissUserCalloutService.new( + callout = Users::DismissCalloutService.new( container: nil, current_user: current_user, params: { feature_name: feature_name } ).execute errors = errors_on_object(callout) diff --git a/app/graphql/types/ci/runner_status_enum.rb b/app/graphql/types/ci/runner_status_enum.rb index 14eae1cdce5..dd056191ceb 100644 --- a/app/graphql/types/ci/runner_status_enum.rb +++ b/app/graphql/types/ci/runner_status_enum.rb @@ -25,13 +25,17 @@ module Types value: :offline value 'STALE', - description: "Runner that has not contacted this instance within the last #{::Ci::Runner::STALE_TIMEOUT.inspect}. Only available if legacyMode is null. Will be a possible return value starting in 15.0", + description: "Runner that has not contacted this instance within the last #{::Ci::Runner::STALE_TIMEOUT.inspect}. Only available if legacyMode is null. Will be a possible return value starting in 15.0.", value: :stale value 'NOT_CONNECTED', description: 'Runner that has never contacted this instance.', - deprecated: { reason: 'This field will have a slightly different scope starting in 15.0, with STALE being returned after a certain period of no contact', milestone: '14.6' }, + deprecated: { reason: "Use NEVER_CONTACTED instead. NEVER_CONTACTED will have a slightly different scope starting in 15.0, with STALE being returned instead after #{::Ci::Runner::STALE_TIMEOUT.inspect} of no contact", milestone: '14.6' }, value: :not_connected + + value 'NEVER_CONTACTED', + description: 'Runner that has never contacted this instance. Set legacyMode to null to utilize this value. Will replace NOT_CONNECTED starting in 15.0.', + value: :never_contacted end end end diff --git a/app/graphql/types/user_callout_feature_name_enum.rb b/app/graphql/types/user_callout_feature_name_enum.rb index 410ca5e1c95..bcb49a709ed 100644 --- a/app/graphql/types/user_callout_feature_name_enum.rb +++ b/app/graphql/types/user_callout_feature_name_enum.rb @@ -5,7 +5,7 @@ module Types graphql_name 'UserCalloutFeatureNameEnum' description 'Name of the feature that the callout is for.' - ::UserCallout.feature_names.keys.each do |feature_name| + ::Users::Callout.feature_names.keys.each do |feature_name| value feature_name.upcase, value: feature_name, description: "Callout feature name for #{feature_name}." end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 58f933a7fe0..02a87979f40 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -206,10 +206,6 @@ module ApplicationHelper 'https://' + promo_host end - def contact_sales_url - promo_url + '/sales' - end - def support_url Gitlab::CurrentSettings.current_application_settings.help_page_support_url.presence || promo_url + '/getting-help/' end diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb index d02fe3f20b0..c7f40decae8 100644 --- a/app/helpers/ci/jobs_helper.rb +++ b/app/helpers/ci/jobs_helper.rb @@ -19,6 +19,13 @@ module Ci } end + def bridge_data(build) + { + "build_name" => build.name, + "empty-state-illustration-path" => image_path('illustrations/job-trigger-md.svg') + } + end + def job_counts { "all" => limited_counter_with_delimiter(@all_builds), diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb index 17057505173..8f219656b71 100644 --- a/app/helpers/ci/runners_helper.rb +++ b/app/helpers/ci/runners_helper.rb @@ -23,7 +23,7 @@ module Ci icon = 'status-paused' span_class = 'gl-text-gray-600' end - when :not_connected + when :not_connected, :never_contacted title = s_("Runners|New runner, has not connected yet") icon = 'warning-solid' when :offline diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index d5d692f2d6e..abb7128470f 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -182,7 +182,7 @@ module MergeRequestsHelper project_path: project_path(merge_request.project), changes_empty_state_illustration: image_path('illustrations/merge_request_changes_empty.svg'), is_fluid_layout: fluid_layout.to_s, - dismiss_endpoint: user_callouts_path, + dismiss_endpoint: callouts_path, show_suggest_popover: show_suggest_popover?.to_s, show_whitespace_default: @show_whitespace_default.to_s, file_by_file_default: @file_by_file_default.to_s, diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb index 3d51ba30c62..2efc3f27dc7 100644 --- a/app/helpers/tab_helper.rb +++ b/app/helpers/tab_helper.rb @@ -14,8 +14,7 @@ module TabHelper gl_tabs_classes = %w[nav gl-tabs-nav] html_options = html_options.merge( - class: [*html_options[:class], gl_tabs_classes].join(' '), - role: 'tablist' + class: [*html_options[:class], gl_tabs_classes].join(' ') ) content = capture(&block) if block_given? @@ -54,7 +53,7 @@ module TabHelper extra_tab_classes = html_options.delete(:tab_class) tab_class = %w[nav-item].push(*extra_tab_classes) - content_tag(:li, class: tab_class, role: 'presentation') do + content_tag(:li, class: tab_class) do if block_given? link_to(options, html_options, &block) else diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb deleted file mode 100644 index d8e69145c40..00000000000 --- a/app/helpers/user_callouts_helper.rb +++ /dev/null @@ -1,98 +0,0 @@ -# frozen_string_literal: true - -module UserCalloutsHelper - GKE_CLUSTER_INTEGRATION = 'gke_cluster_integration' - GCP_SIGNUP_OFFER = 'gcp_signup_offer' - SUGGEST_POPOVER_DISMISSED = 'suggest_popover_dismissed' - TABS_POSITION_HIGHLIGHT = 'tabs_position_highlight' - CUSTOMIZE_HOMEPAGE = 'customize_homepage' - FEATURE_FLAGS_NEW_VERSION = 'feature_flags_new_version' - REGISTRATION_ENABLED_CALLOUT = 'registration_enabled_callout' - UNFINISHED_TAG_CLEANUP_CALLOUT = 'unfinished_tag_cleanup_callout' - INVITE_MEMBERS_BANNER = 'invite_members_banner' - SECURITY_NEWSLETTER_CALLOUT = 'security_newsletter_callout' - - def show_gke_cluster_integration_callout?(project) - active_nav_link?(controller: sidebar_operations_paths) && - can?(current_user, :create_cluster, project) && - !user_dismissed?(GKE_CLUSTER_INTEGRATION) - end - - def show_gcp_signup_offer? - !user_dismissed?(GCP_SIGNUP_OFFER) - end - - def render_flash_user_callout(flash_type, message, feature_name) - render 'shared/flash_user_callout', flash_type: flash_type, message: message, feature_name: feature_name - end - - def render_dashboard_ultimate_trial(user) - end - - def render_two_factor_auth_recovery_settings_check - end - - def show_suggest_popover? - !user_dismissed?(SUGGEST_POPOVER_DISMISSED) - end - - def show_customize_homepage_banner? - current_user.default_dashboard? && !user_dismissed?(CUSTOMIZE_HOMEPAGE) - end - - def show_feature_flags_new_version? - !user_dismissed?(FEATURE_FLAGS_NEW_VERSION) - end - - def show_unfinished_tag_cleanup_callout? - !user_dismissed?(UNFINISHED_TAG_CLEANUP_CALLOUT) - end - - def show_registration_enabled_user_callout? - !Gitlab.com? && - current_user&.admin? && - signup_enabled? && - !user_dismissed?(REGISTRATION_ENABLED_CALLOUT) - end - - def dismiss_two_factor_auth_recovery_settings_check - end - - def show_invite_banner?(group) - Ability.allowed?(current_user, :admin_group, group) && - !just_created? && - !user_dismissed_for_group(INVITE_MEMBERS_BANNER, group) && - !multiple_members?(group) - end - - def show_security_newsletter_user_callout? - current_user&.admin? && - !user_dismissed?(SECURITY_NEWSLETTER_CALLOUT) - end - - private - - def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil) - return false unless current_user - - current_user.dismissed_callout?(feature_name: feature_name, ignore_dismissal_earlier_than: ignore_dismissal_earlier_than) - end - - def user_dismissed_for_group(feature_name, group, ignore_dismissal_earlier_than = nil) - return false unless current_user - - current_user.dismissed_callout_for_group?(feature_name: feature_name, - group: group, - ignore_dismissal_earlier_than: ignore_dismissal_earlier_than) - end - - def just_created? - flash[:notice]&.include?('successfully created') - end - - def multiple_members?(group) - group.member_count > 1 || group.members_with_parents.count > 1 - end -end - -UserCalloutsHelper.prepend_mod diff --git a/app/helpers/users/callouts_helper.rb b/app/helpers/users/callouts_helper.rb new file mode 100644 index 00000000000..5ed17357e9b --- /dev/null +++ b/app/helpers/users/callouts_helper.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Users + module CalloutsHelper + GKE_CLUSTER_INTEGRATION = 'gke_cluster_integration' + GCP_SIGNUP_OFFER = 'gcp_signup_offer' + SUGGEST_POPOVER_DISMISSED = 'suggest_popover_dismissed' + TABS_POSITION_HIGHLIGHT = 'tabs_position_highlight' + CUSTOMIZE_HOMEPAGE = 'customize_homepage' + FEATURE_FLAGS_NEW_VERSION = 'feature_flags_new_version' + REGISTRATION_ENABLED_CALLOUT = 'registration_enabled_callout' + UNFINISHED_TAG_CLEANUP_CALLOUT = 'unfinished_tag_cleanup_callout' + SECURITY_NEWSLETTER_CALLOUT = 'security_newsletter_callout' + + def show_gke_cluster_integration_callout?(project) + active_nav_link?(controller: sidebar_operations_paths) && + can?(current_user, :create_cluster, project) && + !user_dismissed?(GKE_CLUSTER_INTEGRATION) + end + + def show_gcp_signup_offer? + !user_dismissed?(GCP_SIGNUP_OFFER) + end + + def render_flash_user_callout(flash_type, message, feature_name) + render 'shared/flash_user_callout', flash_type: flash_type, message: message, feature_name: feature_name + end + + def render_dashboard_ultimate_trial(user) + end + + def render_two_factor_auth_recovery_settings_check + end + + def show_suggest_popover? + !user_dismissed?(SUGGEST_POPOVER_DISMISSED) + end + + def show_customize_homepage_banner? + current_user.default_dashboard? && !user_dismissed?(CUSTOMIZE_HOMEPAGE) + end + + def show_feature_flags_new_version? + !user_dismissed?(FEATURE_FLAGS_NEW_VERSION) + end + + def show_unfinished_tag_cleanup_callout? + !user_dismissed?(UNFINISHED_TAG_CLEANUP_CALLOUT) + end + + def show_registration_enabled_user_callout? + !Gitlab.com? && + current_user&.admin? && + signup_enabled? && + !user_dismissed?(REGISTRATION_ENABLED_CALLOUT) + end + + def dismiss_two_factor_auth_recovery_settings_check + end + + def show_security_newsletter_user_callout? + current_user&.admin? && + !user_dismissed?(SECURITY_NEWSLETTER_CALLOUT) + end + + private + + def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil) + return false unless current_user + + current_user.dismissed_callout?(feature_name: feature_name, ignore_dismissal_earlier_than: ignore_dismissal_earlier_than) + end + end +end + +Users::CalloutsHelper.prepend_mod diff --git a/app/helpers/users/group_callouts_helper.rb b/app/helpers/users/group_callouts_helper.rb new file mode 100644 index 00000000000..b66c7f9f821 --- /dev/null +++ b/app/helpers/users/group_callouts_helper.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Users + module GroupCalloutsHelper + INVITE_MEMBERS_BANNER = 'invite_members_banner' + + def show_invite_banner?(group) + Ability.allowed?(current_user, :admin_group, group) && + !just_created? && + !user_dismissed_for_group(INVITE_MEMBERS_BANNER, group) && + !multiple_members?(group) + end + + private + + def user_dismissed_for_group(feature_name, group, ignore_dismissal_earlier_than = nil) + return false unless current_user + + current_user.dismissed_callout_for_group?(feature_name: feature_name, + group: group, + ignore_dismissal_earlier_than: ignore_dismissal_earlier_than) + end + + def just_created? + flash[:notice]&.include?('successfully created') + end + + def multiple_members?(group) + group.member_count > 1 || group.members_with_parents.count > 1 + end + end +end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 3ede3ef3347..a441d362b74 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -44,7 +44,7 @@ module Ci AVAILABLE_TYPES_LEGACY = %w[specific shared].freeze AVAILABLE_TYPES = runner_types.keys.freeze - AVAILABLE_STATUSES = %w[active paused online offline not_connected stale].freeze + AVAILABLE_STATUSES = %w[active paused online offline not_connected never_contacted stale].freeze # TODO: Remove in %15.0: active, paused, not_connected. Relevant issues: https://gitlab.com/gitlab-org/gitlab/-/issues/347303, https://gitlab.com/gitlab-org/gitlab/-/issues/347305, https://gitlab.com/gitlab-org/gitlab/-/issues/344648 AVAILABLE_SCOPES = (AVAILABLE_TYPES_LEGACY + AVAILABLE_TYPES + AVAILABLE_STATUSES).freeze FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze @@ -66,7 +66,8 @@ module Ci scope :recent, -> { where('ci_runners.created_at >= :date OR ci_runners.contacted_at >= :date', date: stale_deadline) } scope :stale, -> { where('ci_runners.created_at < :date AND (ci_runners.contacted_at IS NULL OR ci_runners.contacted_at < :date)', date: stale_deadline) } scope :offline, -> { where(arel_table[:contacted_at].lteq(online_contact_time_deadline)) } - scope :not_connected, -> { where(contacted_at: nil) } + scope :not_connected, -> { where(contacted_at: nil) } # TODO: Remove in 15.0 + scope :never_contacted, -> { where(contacted_at: nil) } scope :ordered, -> { order(id: :desc) } scope :with_recent_runner_queue, -> { where('contacted_at > ?', recent_queue_deadline) } @@ -284,7 +285,7 @@ module Ci return deprecated_rest_status if legacy_mode == '14.5' return :stale if stale? - return :not_connected unless contacted_at + return :never_contacted unless contacted_at online? ? :online : :offline end diff --git a/app/models/concerns/calloutable.rb b/app/models/concerns/calloutable.rb deleted file mode 100644 index 8b9cfae6a32..00000000000 --- a/app/models/concerns/calloutable.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module Calloutable - extend ActiveSupport::Concern - - included do - belongs_to :user - - validates :user, presence: true - end - - def dismissed_after?(dismissed_after) - dismissed_at > dismissed_after - end -end diff --git a/app/models/user.rb b/app/models/user.rb index f8579add392..98d2ceb6dbe 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -204,7 +204,7 @@ class User < ApplicationRecord has_many :bulk_imports has_many :custom_attributes, class_name: 'UserCustomAttribute' - has_many :callouts, class_name: 'UserCallout' + has_many :callouts, class_name: 'Users::Callout' has_many :group_callouts, class_name: 'Users::GroupCallout' has_many :term_agreements belongs_to :accepted_term, class_name: 'ApplicationSetting::Term' @@ -1947,7 +1947,7 @@ class User < ApplicationRecord end def find_or_initialize_callout(feature_name) - callouts.find_or_initialize_by(feature_name: ::UserCallout.feature_names[feature_name]) + callouts.find_or_initialize_by(feature_name: ::Users::Callout.feature_names[feature_name]) end def find_or_initialize_group_callout(feature_name, group_id) diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb deleted file mode 100644 index 5956c82384e..00000000000 --- a/app/models/user_callout.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -class UserCallout < ApplicationRecord - include Calloutable - - enum feature_name: { - gke_cluster_integration: 1, - gcp_signup_offer: 2, - cluster_security_warning: 3, - ultimate_trial: 4, # EE-only - geo_enable_hashed_storage: 5, # EE-only - geo_migrate_hashed_storage: 6, # EE-only - canary_deployment: 7, # EE-only - gold_trial_billings: 8, # EE-only - suggest_popover_dismissed: 9, - tabs_position_highlight: 10, - threat_monitoring_info: 11, # EE-only - two_factor_auth_recovery_settings_check: 12, # EE-only - web_ide_alert_dismissed: 16, # no longer in use - active_user_count_threshold: 18, # EE-only - buy_pipeline_minutes_notification_dot: 19, # EE-only - personal_access_token_expiry: 21, # EE-only - suggest_pipeline: 22, - customize_homepage: 23, - feature_flags_new_version: 24, - registration_enabled_callout: 25, - new_user_signups_cap_reached: 26, # EE-only - unfinished_tag_cleanup_callout: 27, - eoa_bronze_plan_banner: 28, # EE-only - pipeline_needs_banner: 29, - pipeline_needs_hover_tip: 30, - web_ide_ci_environments_guidance: 31, - security_configuration_upgrade_banner: 32, - cloud_licensing_subscription_activation_banner: 33, # EE-only - trial_status_reminder_d14: 34, # EE-only - trial_status_reminder_d3: 35, # EE-only - security_configuration_devops_alert: 36, # EE-only - profile_personal_access_token_expiry: 37, # EE-only - terraform_notification_dismissed: 38, - security_newsletter_callout: 39, - verification_reminder: 40 # EE-only - } - - validates :feature_name, - presence: true, - uniqueness: { scope: :user_id }, - inclusion: { in: UserCallout.feature_names.keys } -end diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb new file mode 100644 index 00000000000..9a729072051 --- /dev/null +++ b/app/models/users/callout.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Users + class Callout < ApplicationRecord + include Users::Calloutable + + self.table_name = 'user_callouts' + + enum feature_name: { + gke_cluster_integration: 1, + gcp_signup_offer: 2, + cluster_security_warning: 3, + ultimate_trial: 4, # EE-only + geo_enable_hashed_storage: 5, # EE-only + geo_migrate_hashed_storage: 6, # EE-only + canary_deployment: 7, # EE-only + gold_trial_billings: 8, # EE-only + suggest_popover_dismissed: 9, + tabs_position_highlight: 10, + threat_monitoring_info: 11, # EE-only + two_factor_auth_recovery_settings_check: 12, # EE-only + web_ide_alert_dismissed: 16, # no longer in use + active_user_count_threshold: 18, # EE-only + buy_pipeline_minutes_notification_dot: 19, # EE-only + personal_access_token_expiry: 21, # EE-only + suggest_pipeline: 22, + customize_homepage: 23, + feature_flags_new_version: 24, + registration_enabled_callout: 25, + new_user_signups_cap_reached: 26, # EE-only + unfinished_tag_cleanup_callout: 27, + eoa_bronze_plan_banner: 28, # EE-only + pipeline_needs_banner: 29, + pipeline_needs_hover_tip: 30, + web_ide_ci_environments_guidance: 31, + security_configuration_upgrade_banner: 32, + cloud_licensing_subscription_activation_banner: 33, # EE-only + trial_status_reminder_d14: 34, # EE-only + trial_status_reminder_d3: 35, # EE-only + security_configuration_devops_alert: 36, # EE-only + profile_personal_access_token_expiry: 37, # EE-only + terraform_notification_dismissed: 38, + security_newsletter_callout: 39, + verification_reminder: 40 # EE-only + } + + validates :feature_name, + presence: true, + uniqueness: { scope: :user_id }, + inclusion: { in: Users::Callout.feature_names.keys } + end +end diff --git a/app/models/users/calloutable.rb b/app/models/users/calloutable.rb new file mode 100644 index 00000000000..280a819e4d5 --- /dev/null +++ b/app/models/users/calloutable.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Users + module Calloutable + extend ActiveSupport::Concern + + included do + belongs_to :user + + validates :user, presence: true + end + + def dismissed_after?(dismissed_after) + dismissed_at > dismissed_after + end + end +end diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb index 540d1a1d242..da9b95fd718 100644 --- a/app/models/users/group_callout.rb +++ b/app/models/users/group_callout.rb @@ -2,7 +2,7 @@ module Users class GroupCallout < ApplicationRecord - include Calloutable + include Users::Calloutable self.table_name = 'user_group_callouts' diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb index b310f8fff15..3bd92ebc942 100644 --- a/app/presenters/blob_presenter.rb +++ b/app/presenters/blob_presenter.rb @@ -15,19 +15,8 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated Gitlab::Highlight.highlight( blob.path, - limited_blob_data(to: to), - language: language, - plain: plain - ) - end - - def highlight_transformed(plain: nil) - load_all_blob_data - - Gitlab::Highlight.highlight( - blob.path, - transformed_blob_data, - language: transformed_blob_language, + blob_data(to), + language: blob_language, plain: plain ) end @@ -38,6 +27,14 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated highlight(plain: false) end + def blob_data(to) + @_blob_data ||= Gitlab::Diff::CustomDiff.transformed_blob_data(blob) || limited_blob_data(to: to) + end + + def blob_language + @_blob_language ||= Gitlab::Diff::CustomDiff.transformed_blob_language(blob) || language + end + def raw_plain_data blob.data unless blob.binary? end @@ -134,23 +131,6 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated def language blob.language_from_gitattributes end - - def transformed_blob_language - @transformed_blob_language ||= blob.path.ends_with?('.ipynb') ? 'md' : language - end - - def transformed_blob_data - @transformed_blob ||= if blob.path.ends_with?('.ipynb') && blob.transformed_for_diff - IpynbDiff.transform(blob.data, - raise_errors: true, - options: { include_metadata: false, cell_decorator: :percent }) - end - - @transformed_blob ||= blob.data - rescue IpynbDiff::InvalidNotebookError => e - Gitlab::ErrorTracking.log_exception(e) - blob.data - end end BlobPresenter.prepend_mod_with('BlobPresenter') diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index bd60d60c8db..b9c71e6d97b 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -73,7 +73,7 @@ class MergeRequestWidgetEntity < Grape::Entity end expose :user_callouts_path do |_merge_request| - user_callouts_path + callouts_path end expose :suggest_pipeline_feature_id do |_merge_request| diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index be21ed5b73d..89fe4ff9f60 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -2,6 +2,8 @@ module Ci class RetryBuildService < ::BaseService + include Gitlab::Utils::StrongMemoize + def self.clone_accessors %i[pipeline project ref tag options name allow_failure stage stage_id stage_idx trigger_request @@ -45,6 +47,11 @@ module Ci job.save! end end + + if create_deployment_in_separate_transaction? + clone_deployment!(new_build, build) + end + build.reset # refresh the data to get new values of `retried` and `processed`. new_build @@ -63,7 +70,9 @@ module Ci def clone_build(build) project.builds.new(build_attributes(build)).tap do |new_build| - new_build.assign_attributes(deployment_attributes_for(new_build, build)) + unless create_deployment_in_separate_transaction? + new_build.assign_attributes(deployment_attributes_for(new_build, build)) + end end end @@ -72,6 +81,11 @@ module Ci [attribute, build.public_send(attribute)] # rubocop:disable GitlabSecurity/PublicSend end + if create_deployment_in_separate_transaction? && build.persisted_environment.present? + attributes[:metadata_attributes] ||= {} + attributes[:metadata_attributes][:expanded_environment_name] = build.expanded_environment_name + end + attributes[:user] = current_user attributes end @@ -80,6 +94,26 @@ module Ci ::Gitlab::Ci::Pipeline::Seed::Build .deployment_attributes_for(new_build, old_build.persisted_environment) end + + def clone_deployment!(new_build, old_build) + return unless old_build.deployment.present? + + # We should clone the previous deployment attributes instead of initializing + # new object with `Seed::Deployment`. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/347206 + deployment = ::Gitlab::Ci::Pipeline::Seed::Deployment + .new(new_build, old_build.persisted_environment).to_resource + + return unless deployment + + new_build.create_deployment!(deployment.attributes) + end + + def create_deployment_in_separate_transaction? + strong_memoize(:create_deployment_in_separate_transaction) do + ::Feature.enabled?(:create_deployment_in_separate_transaction, project, default_enabled: :yaml) + end + end end end diff --git a/app/services/merge_requests/outdated_discussion_diff_lines_service.rb b/app/services/merge_requests/outdated_discussion_diff_lines_service.rb index ad65a9afa6b..a3d94e888df 100644 --- a/app/services/merge_requests/outdated_discussion_diff_lines_service.rb +++ b/app/services/merge_requests/outdated_discussion_diff_lines_service.rb @@ -15,12 +15,22 @@ module MergeRequests def execute line_position = position.line_range["end"] || position.line_range["start"] - diff_line_index = diff_lines.find_index do |l| - if line_position["new_line"] - l.new_line == line_position["new_line"] - elsif line_position["old_line"] - l.old_line == line_position["old_line"] + found_line = false + diff_line_index = -1 + diff_lines.each_with_index do |l, i| + if found_line + if !l.type + break + elsif l.type == 'new' + diff_line_index = i + break + end + else + # Find the old line + found_line = l.old_line == line_position["new_line"] end + + diff_line_index = i end initial_line_index = [diff_line_index - OVERFLOW_LINES_COUNT, 0].max last_line_index = [diff_line_index + OVERFLOW_LINES_COUNT, diff_lines.length].min diff --git a/app/services/users/dismiss_user_callout_service.rb b/app/services/users/dismiss_callout_service.rb index 96f3f3acb57..4324e6232c2 100644 --- a/app/services/users/dismiss_user_callout_service.rb +++ b/app/services/users/dismiss_callout_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Users - class DismissUserCalloutService < BaseContainerService + class DismissCalloutService < BaseContainerService def execute callout.tap do |record| record.update(dismissed_at: Time.current) if record.valid? diff --git a/app/services/users/dismiss_group_callout_service.rb b/app/services/users/dismiss_group_callout_service.rb index 8afee6a8187..f482142b911 100644 --- a/app/services/users/dismiss_group_callout_service.rb +++ b/app/services/users/dismiss_group_callout_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Users - class DismissGroupCalloutService < DismissUserCalloutService + class DismissGroupCalloutService < DismissCalloutService private def callout diff --git a/app/views/admin/dashboard/_security_newsletter_callout.html.haml b/app/views/admin/dashboard/_security_newsletter_callout.html.haml index ece0f7ca4d9..3aba91e8765 100644 --- a/app/views/admin/dashboard/_security_newsletter_callout.html.haml +++ b/app/views/admin/dashboard/_security_newsletter_callout.html.haml @@ -5,7 +5,7 @@ variant: :tip, alert_class: 'js-security-newsletter-callout', is_contained: true, - alert_data: { feature_id: UserCalloutsHelper::SECURITY_NEWSLETTER_CALLOUT, dismiss_endpoint: user_callouts_path, defer_links: 'true' }, + alert_data: { feature_id: Users::CalloutsHelper::SECURITY_NEWSLETTER_CALLOUT, dismiss_endpoint: callouts_path, defer_links: 'true' }, close_button_data: { testid: 'close-security-newsletter-callout' } do .gl-alert-body = s_('AdminArea|Sign up for the GitLab Security Newsletter to get notified for security updates.') diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml index 81f4be9fce5..9d249931a34 100644 --- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml +++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml @@ -1,5 +1,5 @@ - link = link_to(s_('ClusterIntegration|sign up'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer') -.gcp-signup-offer.gl-alert.gl-alert-info.gl-my-3{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } } +.gcp-signup-offer.gl-alert.gl-alert-info.gl-my-3{ role: 'alert', data: { feature_id: Users::CalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: callouts_path } } .gl-alert-container %button.js-close.btn.gl-dismiss-btn.btn-default.btn-sm.gl-button.btn-default-tertiary.btn-icon{ type: 'button', 'aria-label' => _('Dismiss') } = sprite_icon('close', size: 16, css_class: 'gl-icon') diff --git a/app/views/devise/shared/_tab_single.html.haml b/app/views/devise/shared/_tab_single.html.haml index 5683b4207b4..1b5a932a09a 100644 --- a/app/views/devise/shared/_tab_single.html.haml +++ b/app/views/devise/shared/_tab_single.html.haml @@ -1,3 +1,2 @@ -%ul.nav-links.new-session-tabs.single-tab.nav-tabs.nav - %li.nav-item - %a.nav-link.active= tab_title += gl_tabs_nav({ class: 'new-session-tabs gl-border-0' }) do + = gl_tab_link_to tab_title, '#', { item_active: true, class: 'gl-cursor-default!', tab_class: 'gl-bg-transparent!', tabindex: '-1' } diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml index 2901c8fa46b..f6d05959d2e 100644 --- a/app/views/groups/registry/repositories/index.html.haml +++ b/app/views/groups/registry/repositories/index.html.haml @@ -18,6 +18,6 @@ "gid_prefix": container_repository_gid_prefix, connection_error: (!!@connection_error).to_s, invalid_path_error: (!!@invalid_path_error).to_s, - user_callouts_path: user_callouts_path, - user_callout_id: UserCalloutsHelper::UNFINISHED_TAG_CLEANUP_CALLOUT, + user_callouts_path: callouts_path, + user_callout_id: Users::CalloutsHelper::UNFINISHED_TAG_CLEANUP_CALLOUT, show_unfinished_tag_cleanup_callout: show_unfinished_tag_cleanup_callout?.to_s } } diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index ed3f2b0c6db..bb409190dd8 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -15,7 +15,7 @@ track_label: 'invite_members_banner', invite_members_path: group_group_members_path(@group), callouts_path: group_callouts_path, - callouts_feature_id: UserCalloutsHelper::INVITE_MEMBERS_BANNER, + callouts_feature_id: Users::GroupCalloutsHelper::INVITE_MEMBERS_BANNER, group_id: @group.id } } = render 'groups/invite_members_modal', group: @group diff --git a/app/views/layouts/header/_registration_enabled_callout.html.haml b/app/views/layouts/header/_registration_enabled_callout.html.haml index 25a7f7ba9d7..90f3ac61614 100644 --- a/app/views/layouts/header/_registration_enabled_callout.html.haml +++ b/app/views/layouts/header/_registration_enabled_callout.html.haml @@ -4,7 +4,7 @@ title: _('Open registration is enabled on your instance.'), variant: :warning, alert_class: 'js-registration-enabled-callout', - alert_data: { feature_id: UserCalloutsHelper::REGISTRATION_ENABLED_CALLOUT, dismiss_endpoint: user_callouts_path }, + alert_data: { feature_id: Users::CalloutsHelper::REGISTRATION_ENABLED_CALLOUT, dismiss_endpoint: callouts_path }, close_button_data: { testid: 'close-registration-enabled-callout' } do .gl-alert-body = html_escape(_('%{anchorOpen}Learn more%{anchorClose} about how you can customize / disable registration on your instance.')) % { anchorOpen: "<a href=\"#{help_page_path('user/admin_area/settings/sign_up_restrictions')}\" class=\"gl-link\">".html_safe, anchorClose: '</a>'.html_safe } diff --git a/app/views/projects/feature_flags/new.html.haml b/app/views/projects/feature_flags/new.html.haml index 097475d2928..9fef9864475 100644 --- a/app/views/projects/feature_flags/new.html.haml +++ b/app/views/projects/feature_flags/new.html.haml @@ -6,8 +6,8 @@ #js-new-feature-flag{ data: { endpoint: project_feature_flags_path(@project, format: :json), feature_flags_path: project_feature_flags_path(@project), environments_endpoint: search_project_environments_path(@project, format: :json), - user_callouts_path: user_callouts_path, - user_callout_id: UserCalloutsHelper::FEATURE_FLAGS_NEW_VERSION, + user_callouts_path: callouts_path, + user_callout_id: Users::CalloutsHelper::FEATURE_FLAGS_NEW_VERSION, show_user_callout: show_feature_flags_new_version?.to_s, strategy_type_docs_page_path: help_page_path('operations/feature_flags', anchor: 'feature-flag-strategies'), environments_scope_docs_path: help_page_path('ci/environments/index.md', anchor: 'scope-environments-with-specs'), diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml index 44336b95e0f..7af825b2819 100644 --- a/app/views/projects/jobs/show.html.haml +++ b/app/views/projects/jobs/show.html.haml @@ -7,4 +7,7 @@ = render_if_exists "shared/shared_runners_minutes_limit_flash_message" -#js-job-vue-app{ data: jobs_data } +- if @build.is_a? ::Ci::Build + #js-job-page{ data: jobs_data } +- else + #js-bridge-page{ data: bridge_data(@build) } diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index cfdbf3410b1..03927cd3bfa 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -22,6 +22,6 @@ "cleanup_policies_settings_path": project_settings_packages_and_registries_path(@project), connection_error: (!!@connection_error).to_s, invalid_path_error: (!!@invalid_path_error).to_s, - user_callouts_path: user_callouts_path, - user_callout_id: UserCalloutsHelper::UNFINISHED_TAG_CLEANUP_CALLOUT, + user_callouts_path: callouts_path, + user_callout_id: Users::CalloutsHelper::UNFINISHED_TAG_CLEANUP_CALLOUT, show_unfinished_tag_cleanup_callout: show_unfinished_tag_cleanup_callout?.to_s, } } diff --git a/app/views/root/index.html.haml b/app/views/root/index.html.haml index 97dd8e133f5..4b1ac213d68 100644 --- a/app/views/root/index.html.haml +++ b/app/views/root/index.html.haml @@ -3,8 +3,8 @@ .gl-display-none.gl-md-display-block{ class: "gl-pt-6! gl-pb-2! #{(container_class unless @no_container)} #{@content_class}" } .js-customize-homepage-banner{ data: { svg_path: image_path('illustrations/monitoring/getting_started.svg'), preferences_behavior_path: profile_preferences_path(anchor: 'behavior'), - callouts_path: user_callouts_path, - callouts_feature_id: UserCalloutsHelper::CUSTOMIZE_HOMEPAGE, + callouts_path: callouts_path, + callouts_feature_id: Users::CalloutsHelper::CUSTOMIZE_HOMEPAGE, track_label: 'home_page' } } = render template: 'dashboard/projects/index' diff --git a/app/views/shared/_flash_user_callout.html.haml b/app/views/shared/_flash_user_callout.html.haml index d8032ac521d..7b2d59407b4 100644 --- a/app/views/shared/_flash_user_callout.html.haml +++ b/app/views/shared/_flash_user_callout.html.haml @@ -1,4 +1,4 @@ -- callout_data = { uid: "callout_feature_#{feature_name}_dismissed", feature_id: feature_name, dismiss_endpoint: user_callouts_path } +- callout_data = { uid: "callout_feature_#{feature_name}_dismissed", feature_id: feature_name, dismiss_endpoint: callouts_path } - extra_flash_class = local_assigns.fetch(:extra_flash_class, nil) .flash-container.flash-container-page.user-callout{ data: callout_data } diff --git a/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml b/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml index d4764d1a5d9..e7239661313 100644 --- a/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml +++ b/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml @@ -1,7 +1,7 @@ = render 'shared/global_alert', variant: :warning, alert_class: 'js-recovery-settings-callout', - alert_data: { feature_id: UserCalloutsHelper::TWO_FACTOR_AUTH_RECOVERY_SETTINGS_CHECK, dismiss_endpoint: user_callouts_path, defer_links: 'true' }, + alert_data: { feature_id: Users::CalloutsHelper::TWO_FACTOR_AUTH_RECOVERY_SETTINGS_CHECK, dismiss_endpoint: callouts_path, defer_links: 'true' }, close_button_data: { testid: 'close-account-recovery-regular-check-callout' } do .gl-alert-body = s_('Profiles|Ensure you have two-factor authentication recovery codes stored in a safe place.') diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml index 3524a1b17ea..8c49977fe82 100644 --- a/app/views/shared/milestones/_tabs.html.haml +++ b/app/views/shared/milestones/_tabs.html.haml @@ -3,24 +3,20 @@ .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller .fade-left= sprite_icon('chevron-lg-left', size: 12) .fade-right= sprite_icon('chevron-lg-right', size: 12) - %ul.nav-links.scrolling-tabs.js-milestone-tabs.nav.nav-tabs - %li.nav-item - = link_to '#tab-issues', class: 'nav-link active', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'issues', show_project_name: show_project_name) } do - = _('Issues') - %span.badge.badge-pill= milestone.issues_visible_to_user(current_user).size + = gl_tabs_nav({ class: %w[scrolling-tabs js-milestone-tabs] }) do + = gl_tab_link_to '#tab-issues', item_active: true, data: { endpoint: milestone_tab_path(milestone, 'issues', show_project_name: show_project_name) } do + = _('Issues') + = gl_tab_counter_badge milestone.issues_visible_to_user(current_user).size - if milestone.merge_requests_enabled? - %li.nav-item - = link_to '#tab-merge-requests', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'merge_requests', show_project_name: show_project_name) } do - = _('Merge requests') - %span.badge.badge-pill= milestone.merge_requests_visible_to_user(current_user).size - %li.nav-item - = link_to '#tab-participants', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'participants') } do - = _('Participants') - %span.badge.badge-pill= milestone.issue_participants_visible_by_user(current_user).count - %li.nav-item - = link_to '#tab-labels', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'labels') } do - = _('Labels') - %span.badge.badge-pill= milestone.issue_labels_visible_by_user(current_user).count + = gl_tab_link_to '#tab-merge-requests', data: { endpoint: milestone_tab_path(milestone, 'merge_requests', show_project_name: show_project_name) } do + = _('Merge requests') + = gl_tab_counter_badge milestone.merge_requests_visible_to_user(current_user).size + = gl_tab_link_to '#tab-participants', data: { endpoint: milestone_tab_path(milestone, 'participants') } do + = _('Participants') + = gl_tab_counter_badge milestone.issue_participants_visible_by_user(current_user).count + = gl_tab_link_to '#tab-labels', data: { endpoint: milestone_tab_path(milestone, 'labels') } do + = _('Labels') + = gl_tab_counter_badge milestone.issue_labels_visible_by_user(current_user).count .tab-content.milestone-content .tab-pane.active#tab-issues |