diff options
38 files changed, 1002 insertions, 192 deletions
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index 741171b185a..1287a7ed746 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -9,6 +9,8 @@ const FLASH_TYPES = { WARNING: 'warning', }; +const FLASH_CLOSED_EVENT = 'flashClosed'; + const getCloseEl = (flashEl) => { return flashEl.querySelector('.js-close-icon'); }; @@ -26,6 +28,7 @@ const hideFlash = (flashEl, fadeTransition = true) => { () => { flashEl.remove(); window.dispatchEvent(new Event('resize')); + flashEl.dispatchEvent(new Event(FLASH_CLOSED_EVENT)); if (document.body.classList.contains('flash-shown')) document.body.classList.remove('flash-shown'); }, @@ -132,4 +135,5 @@ export { hideFlash, removeFlashClickListener, FLASH_TYPES, + FLASH_CLOSED_EVENT, }; diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js index 8a8d38b295c..d214ee4ded6 100644 --- a/app/assets/javascripts/integrations/constants.js +++ b/app/assets/javascripts/integrations/constants.js @@ -1,4 +1,4 @@ -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; export const TEST_INTEGRATION_EVENT = 'testIntegration'; export const SAVE_INTEGRATION_EVENT = 'saveIntegration'; @@ -21,3 +21,9 @@ export const overrideDropdownDescriptions = { 'Integrations|Default settings are inherited from the instance level.', ), }; + +export const I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE = s__( + 'Integrations|Connection failed. Please check your settings.', +); +export const I18N_DEFAULT_ERROR_MESSAGE = __('Something went wrong on our end.'); +export const I18N_SUCCESSFUL_CONNECTION_MESSAGE = s__('Integrations|Connection successful.'); diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js index f33364d5545..f519fc87c46 100644 --- a/app/assets/javascripts/integrations/integration_settings_form.js +++ b/app/assets/javascripts/integrations/integration_settings_form.js @@ -1,5 +1,4 @@ import { delay } from 'lodash'; -import { __, s__ } from '~/locale'; import toast from '~/vue_shared/plugins/global_toast'; import axios from '../lib/utils/axios_utils'; import initForm from './edit'; @@ -10,6 +9,9 @@ import { GET_JIRA_ISSUE_TYPES_EVENT, TOGGLE_INTEGRATION_EVENT, VALIDATE_INTEGRATION_FORM_EVENT, + I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE, + I18N_DEFAULT_ERROR_MESSAGE, + I18N_SUCCESSFUL_CONNECTION_MESSAGE, } from './constants'; export default class IntegrationSettingsForm { @@ -104,11 +106,7 @@ export default class IntegrationSettingsForm { return this.fetchTestSettings(formData) .then( ({ - data: { - issuetypes, - error, - message = s__('Integrations|Connection failed. Please check your settings.'), - }, + data: { issuetypes, error, message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE }, }) => { if (error || !issuetypes?.length) { eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); @@ -118,7 +116,7 @@ export default class IntegrationSettingsForm { dispatch('receiveJiraIssueTypesSuccess', issuetypes); }, ) - .catch(({ message = __('Something went wrong on our end.') }) => { + .catch(({ message = I18N_DEFAULT_ERROR_MESSAGE }) => { dispatch('receiveJiraIssueTypesError', message); }); } @@ -140,11 +138,11 @@ export default class IntegrationSettingsForm { toast(`${data.message} ${data.service_response}`); } else { this.vue.$store.dispatch('receiveJiraIssueTypesSuccess', data.issuetypes); - toast(s__('Integrations|Connection successful.')); + toast(I18N_SUCCESSFUL_CONNECTION_MESSAGE); } }) .catch(() => { - toast(__('Something went wrong on our end.')); + toast(I18N_DEFAULT_ERROR_MESSAGE); }) .finally(() => { this.vue.$store.dispatch('setIsTesting', false); diff --git a/app/assets/javascripts/pages/users/terms/index/index.js b/app/assets/javascripts/pages/users/terms/index/index.js new file mode 100644 index 00000000000..29ddde6da94 --- /dev/null +++ b/app/assets/javascripts/pages/users/terms/index/index.js @@ -0,0 +1,4 @@ +import { initTermsApp } from '~/terms'; +import { waitForCSSLoaded } from '~/helpers/startup_css_helper'; + +waitForCSSLoaded(initTermsApp); diff --git a/app/assets/javascripts/terms/components/app.vue b/app/assets/javascripts/terms/components/app.vue new file mode 100644 index 00000000000..aedf5b6acfe --- /dev/null +++ b/app/assets/javascripts/terms/components/app.vue @@ -0,0 +1,117 @@ +<script> +import $ from 'jquery'; +import { GlButton, GlIntersectionObserver, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; + +import { FLASH_TYPES, FLASH_CLOSED_EVENT } from '~/flash'; +import { isLoggedIn } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import csrf from '~/lib/utils/csrf'; +import '~/behaviors/markdown/render_gfm'; + +export default { + name: 'TermsApp', + i18n: { + accept: __('Accept terms'), + continue: __('Continue'), + decline: __('Decline and sign out'), + }, + flashElements: [], + csrf, + directives: { + SafeHtml, + }, + components: { GlButton, GlIntersectionObserver }, + inject: ['terms', 'permissions', 'paths'], + data() { + return { + acceptDisabled: true, + }; + }, + computed: { + isLoggedIn, + }, + mounted() { + this.renderGFM(); + this.setScrollableViewportHeight(); + + this.$options.flashElements = [ + ...document.querySelectorAll( + Object.values(FLASH_TYPES) + .map((flashType) => `.flash-${flashType}`) + .join(','), + ), + ]; + + this.$options.flashElements.forEach((flashElement) => { + flashElement.addEventListener(FLASH_CLOSED_EVENT, this.handleFlashClose); + }); + }, + beforeDestroy() { + this.$options.flashElements.forEach((flashElement) => { + flashElement.removeEventListener(FLASH_CLOSED_EVENT, this.handleFlashClose); + }); + }, + methods: { + renderGFM() { + $(this.$refs.gfmContainer).renderGFM(); + }, + handleBottomReached() { + this.acceptDisabled = false; + }, + setScrollableViewportHeight() { + // Reset `max-height` inline style + this.$refs.scrollableViewport.style.maxHeight = ''; + + const { scrollHeight, clientHeight } = document.documentElement; + + // Set `max-height` to 100vh minus all elements that are NOT the scrollable viewport (header, footer, alerts, etc) + this.$refs.scrollableViewport.style.maxHeight = `calc(100vh - ${ + scrollHeight - clientHeight + }px)`; + }, + handleFlashClose(event) { + this.setScrollableViewportHeight(); + event.target.removeEventListener(FLASH_CLOSED_EVENT, this.handleFlashClose); + }, + }, +}; +</script> + +<template> + <div> + <div class="gl-card-body gl-relative gl-pb-0 gl-px-0" data-qa-selector="terms_content"> + <div + class="terms-fade gl-absolute gl-left-5 gl-right-5 gl-bottom-0 gl-h-11 gl-pointer-events-none" + ></div> + <div + ref="scrollableViewport" + data-testid="scrollable-viewport" + class="gl-h-100vh gl-overflow-y-auto gl-pb-11 gl-px-5" + > + <div ref="gfmContainer" v-safe-html="terms"></div> + <gl-intersection-observer @appear="handleBottomReached"> + <div></div> + </gl-intersection-observer> + </div> + </div> + <div v-if="isLoggedIn" class="gl-card-footer gl-display-flex gl-justify-content-end"> + <form v-if="permissions.canDecline" method="post" :action="paths.decline"> + <gl-button type="submit">{{ $options.i18n.decline }}</gl-button> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + </form> + <form v-if="permissions.canAccept" class="gl-ml-3" method="post" :action="paths.accept"> + <gl-button + type="submit" + variant="confirm" + :disabled="acceptDisabled" + data-qa-selector="accept_terms_button" + >{{ $options.i18n.accept }}</gl-button + > + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + </form> + <gl-button v-else class="gl-ml-3" :href="paths.root" variant="confirm">{{ + $options.i18n.continue + }}</gl-button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/terms/index.js b/app/assets/javascripts/terms/index.js new file mode 100644 index 00000000000..dc4f1190eb8 --- /dev/null +++ b/app/assets/javascripts/terms/index.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; + +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import TermsApp from './components/app.vue'; + +export const initTermsApp = () => { + const el = document.getElementById('js-terms-of-service'); + + if (!el) return false; + + const { terms, permissions, paths } = convertObjectPropsToCamelCase( + JSON.parse(el.dataset.termsData), + { deep: true }, + ); + + return new Vue({ + el, + provide: { terms, permissions, paths }, + render(createElement) { + return createElement(TermsApp); + }, + }); +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue index cbace1ad57c..f4f611dfd1b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue @@ -12,13 +12,12 @@ import { CANCELED, SKIPPED, } from './constants'; -import MemoryUsage from './memory_usage.vue'; export default { name: 'DeploymentInfo', components: { GlLink, - MemoryUsage, + MemoryUsage: () => import('./memory_usage.vue'), TooltipOnTruncate, }, directives: { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue index d3384903cce..655acf28253 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue @@ -2,10 +2,11 @@ import { GlSprintf } from '@gitlab/ui'; import { n__ } from '~/locale'; import MrCollapsibleExtension from '../mr_collapsible_extension.vue'; +import Deployment from './deployment.vue'; export default { components: { - Deployment: () => import('./deployment.vue'), + Deployment, GlSprintf, MrCollapsibleExtension, }, diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 06a8694eb3d..c1c8bfffff7 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -62,7 +62,6 @@ @import 'framework/sortable'; @import 'framework/ci_variable_list'; @import 'framework/feature_highlight'; -@import 'framework/terms'; @import 'framework/read_more'; @import 'framework/flex_grid'; @import 'framework/system_messages'; diff --git a/app/assets/stylesheets/framework/terms.scss b/app/assets/stylesheets/page_bundles/terms.scss index b07d6023127..8eb66e58aed 100644 --- a/app/assets/stylesheets/framework/terms.scss +++ b/app/assets/stylesheets/page_bundles/terms.scss @@ -1,18 +1,22 @@ +@import 'mixins_and_variables_and_functions'; + .terms { + .with-system-header &, + .with-system-header.with-performance-bar &, .with-performance-bar & { margin-top: 0; } - .alert-wrapper { - min-height: $header-height + $gl-padding; + .terms-fade { + background: linear-gradient(0deg, $white 0%, rgba($white, 0.5) 100%); } .content { padding-top: $gl-padding; } - .card { - .card-header { + .gl-card { + .gl-card-header { display: flex; align-items: center; justify-content: space-between; diff --git a/app/helpers/terms_helper.rb b/app/helpers/terms_helper.rb new file mode 100644 index 00000000000..5f321551413 --- /dev/null +++ b/app/helpers/terms_helper.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module TermsHelper + def terms_data(terms, redirect) + redirect_params = { redirect: redirect } if redirect + + { + terms: markdown_field(terms, :terms), + permissions: { + can_accept: can?(current_user, :accept_terms, terms), + can_decline: can?(current_user, :decline_terms, terms) + }, + paths: { + accept: accept_term_path(terms, redirect_params), + decline: decline_term_path(terms, redirect_params), + root: root_path + } + }.to_json + end +end diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 25ae0b30486..59a9251d6b7 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.31.0' + VERSION = '0.34.0' self.table_name = 'clusters_applications_runners' diff --git a/app/views/layouts/terms.html.haml b/app/views/layouts/terms.html.haml index 4d5c354388f..caa46b7bc56 100644 --- a/app/views/layouts/terms.html.haml +++ b/app/views/layouts/terms.html.haml @@ -1,21 +1,22 @@ !!! 5 +- add_page_specific_style 'page_bundles/terms' - @hide_breadcrumbs = true %html{ lang: I18n.locale, class: page_class } = render "layouts/head" %body{ data: { page: body_data_page } } .layout-page.terms{ class: page_class } - .content-wrapper + .content-wrapper.gl-pb-5 .mobile-overlay .alert-wrapper = render "layouts/broadcast" = render 'layouts/header/read_only_banner' - = render "layouts/flash", extra_flash_class: 'limit-container-width' + = render "layouts/flash" %div{ class: "#{container_class} limit-container-width" } .content{ id: "content-body" } - .card - .card-header + .gl-card + .gl-card-header = brand_header_logo - logo_text = brand_header_logo_type - if logo_text.present? diff --git a/app/views/notify/in_product_marketing_email.html.haml b/app/views/notify/in_product_marketing_email.html.haml index 575e967d5f8..a85fa7c519f 100644 --- a/app/views/notify/in_product_marketing_email.html.haml +++ b/app/views/notify/in_product_marketing_email.html.haml @@ -166,10 +166,11 @@ = about_link('mailers/in_product_marketing/gitlab-logo-gray-rgb.png', 200) %tr %td{ "aria-hidden" => "true", height: "30", style: "font-size: 0; line-height: 0;" } - %tr{ style: "background-color: #ffffff;" } - %td{ style: "color: #424242; padding: 10px 30px; text-align: center; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;font-size: 16px; line-height: 22px; border: 1px solid #dddddd" } - %p - = @message.progress.html_safe + - if @message.series? + %tr{ style: "background-color: #ffffff;" } + %td{ style: "color: #424242; padding: 10px 30px; text-align: center; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;font-size: 16px; line-height: 22px; border: 1px solid #dddddd" } + %p + = @message.progress.html_safe %tr %td{ bgcolor: "#ffffff", height: "auto", style: "max-width: 600px; width: 100%; text-align: center; height: 200px; padding: 25px 15px; mso-line-height-rule: exactly; min-height: 40px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;", valign: "middle", width: "100%" } = inline_image_link(@message.logo_path, { width: '150', style: 'width: 150px;' }) diff --git a/app/views/users/terms/index.html.haml b/app/views/users/terms/index.html.haml index 771ee693120..92095e78f69 100644 --- a/app/views/users/terms/index.html.haml +++ b/app/views/users/terms/index.html.haml @@ -1,20 +1,23 @@ - redirect_params = { redirect: @redirect } if @redirect - accept_term_link = accept_term_path(@term, redirect_params) -.card-body.rendered-terms{ data: { qa_selector: 'terms_content' } } - = markdown_field(@term, :terms) -- if current_user - = render_if_exists 'devise/shared/form_phone_verification', accept_term_link: accept_term_link, inline: true - .card-footer.footer-block.clearfix - - if can?(current_user, :accept_terms, @term) - .float-right - = button_to accept_term_link, class: 'gl-button btn btn-confirm gl-ml-3', data: { qa_selector: 'accept_terms_button' } do - = _('Accept terms') - - else - .float-right - = link_to root_path, class: 'gl-button btn btn-confirm gl-ml-3' do - = _('Continue') - - if can?(current_user, :decline_terms, @term) - .float-right - = button_to decline_term_path(@term, redirect_params), class: 'gl-button btn btn-default gl-ml-3' do - = _('Decline and sign out') +- if Feature.enabled?(:terms_of_service_vue, current_user, default_enabled: :yaml) + #js-terms-of-service{ data: { terms_data: terms_data(@term, @redirect) } } +- else + .card-body.rendered-terms{ data: { qa_selector: 'terms_content' } } + = markdown_field(@term, :terms) + - if current_user + = render_if_exists 'devise/shared/form_phone_verification', accept_term_link: accept_term_link, inline: true + .card-footer.footer-block.clearfix + - if can?(current_user, :accept_terms, @term) + .float-right + = button_to accept_term_link, class: 'gl-button btn btn-confirm gl-ml-3', data: { qa_selector: 'accept_terms_button' } do + = _('Accept terms') + - else + .float-right + = link_to root_path, class: 'gl-button btn btn-confirm gl-ml-3' do + = _('Continue') + - if can?(current_user, :decline_terms, @term) + .float-right + = button_to decline_term_path(@term, redirect_params), class: 'gl-button btn btn-default gl-ml-3' do + = _('Decline and sign out') diff --git a/config/application.rb b/config/application.rb index 055a89efa6a..5cc9a347d9e 100644 --- a/config/application.rb +++ b/config/application.rb @@ -255,6 +255,7 @@ module Gitlab config.assets.precompile << "page_bundles/security_discover.css" config.assets.precompile << "page_bundles/signup.css" config.assets.precompile << "page_bundles/terminal.css" + config.assets.precompile << "page_bundles/terms.css" config.assets.precompile << "page_bundles/todos.css" config.assets.precompile << "page_bundles/wiki.css" config.assets.precompile << "page_bundles/xterm.css" diff --git a/config/feature_flags/development/terms_of_service_vue.yml b/config/feature_flags/development/terms_of_service_vue.yml new file mode 100644 index 00000000000..d80af2ab716 --- /dev/null +++ b/config/feature_flags/development/terms_of_service_vue.yml @@ -0,0 +1,8 @@ +--- +name: terms_of_service_vue +introduced_by_url: +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/343046 +milestone: '14.5' +type: development +group: group::access +default_enabled: false diff --git a/doc/administration/geo/index.md b/doc/administration/geo/index.md index 48091967189..e43dfab20ed 100644 --- a/doc/administration/geo/index.md +++ b/doc/administration/geo/index.md @@ -148,6 +148,18 @@ NOTE: When using HTTPS protocol for port 443, you need to add an SSL certificate to the load balancers. If you wish to terminate SSL at the GitLab application server instead, use TCP protocol. +#### Internal URL + +HTTP requests from any Geo secondary site to the primary Geo site use the Internal URL of the primary +Geo site. If this is not explicitly defined in the primary Geo site settings in the Admin Area, the +public URL of the primary site will be used. + +To update the internal URL of the primary Geo site: + +1. On the top bar, go to **Menu > Admin > Geo > Sites**. +1. Select **Edit** on the primary site. +1. Change the **Internal URL**, then select **Save changes**. + ### LDAP We recommend that if you use LDAP on your **primary** site, you also set up secondary LDAP servers on each **secondary** site. Otherwise, users are unable to perform Git operations over HTTP(s) on the **secondary** site using HTTP Basic Authentication. However, Git via SSH and personal access tokens still works. @@ -258,6 +270,10 @@ For information on using Geo in disaster recovery situations to mitigate data-lo For more information on how to replicate the Container Registry, see [Docker Registry for a **secondary** site](replication/docker_registry.md). +### Geo secondary proxy + +For more information on using Geo proxying on secondary nodes, see [Geo proxying for secondary sites](secondary_proxy/index.md). + ### Security Review For more information on Geo security, see [Geo security review](replication/security_review.md). diff --git a/doc/administration/geo/secondary_proxy/img/single_url_add_traffic_policy_endpoints.png b/doc/administration/geo/secondary_proxy/img/single_url_add_traffic_policy_endpoints.png Binary files differnew file mode 100644 index 00000000000..d1c4afceb04 --- /dev/null +++ b/doc/administration/geo/secondary_proxy/img/single_url_add_traffic_policy_endpoints.png diff --git a/doc/administration/geo/secondary_proxy/img/single_url_create_policy_records_with_traffic_policy.png b/doc/administration/geo/secondary_proxy/img/single_url_create_policy_records_with_traffic_policy.png Binary files differnew file mode 100644 index 00000000000..3bad391f220 --- /dev/null +++ b/doc/administration/geo/secondary_proxy/img/single_url_create_policy_records_with_traffic_policy.png diff --git a/doc/administration/geo/secondary_proxy/index.md b/doc/administration/geo/secondary_proxy/index.md new file mode 100644 index 00000000000..5a951247649 --- /dev/null +++ b/doc/administration/geo/secondary_proxy/index.md @@ -0,0 +1,127 @@ +--- +stage: Enablement +group: Geo +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments +type: howto +--- + +# Geo proxying for secondary sites **(PREMIUM SELF)** + +> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/5914) in GitLab 14.4 [with a flag](../../feature_flags.md) named `geo_secondary_proxy`. Disabled by default. + +FLAG: +On self-managed GitLab, by default this feature is not available. See below to [Set up a unified URL for Geo sites](#set-up-a-unified-url-for-geo-sites). +The feature is not ready for production use. + +Use Geo proxying to: + +- Have secondary sites serve read-write traffic by proxying to the primary site. +- Selectively accelerate replicated data types by directing read-only operations to the local site instead. + +When enabled, users of the secondary site can use the WebUI as if they were accessing the +primary site's UI. This significantly improves the overall user experience of secondary sites. + +With secondary proxying, web requests to secondary Geo sites are +proxied directly to the primary, and appear to act as a read-write site. + +Proxying is done by the [`gitlab-workhorse`](https://gitlab.com/gitlab-org/gitlab-workhorse) component. +Traffic usually sent to the Rails application on the Geo secondary site is proxied +to the [internal URL](../index.md#internal-url) of the primary Geo site instead. + +Use secondary proxying for use-cases including: + +- Having all Geo sites behind a single URL. +- Geographically load-balancing traffic without worrying about write access. + +## Set up a unified URL for Geo sites + +Secondary sites can transparently serve read-write traffic. You can +use a single external URL so that requests can hit either the primary Geo site +or any secondary Geo sites that use Geo proxying. + +### Configure an external URL to send traffic to both sites + +Follow the [Location-aware public URL](location_aware_external_url.md) steps to create +a single URL used by all Geo sites, including the primary. + +### Update the Geo sites to use the same external URL + +1. On your Geo sites, SSH **into each node running Rails (Puma, Sidekiq, Log-Cursor) + and change the `external_url` to that of the single URL: + + ```shell + sudo editor /etc/gitlab/gitlab.rb + ``` + +1. Reconfigure the updated nodes for the change to take effect if the URL was different than the one already set: + + ```shell + gitlab-ctl reconfigure + ``` + +1. To match the new external URL set on the secondary Geo sites, the primary database + needs to reflect this change. + + In the Geo administration page of the **primary** site, edit each Geo secondary that + is using the secondary proxying and set the `URL` field to the single URL. + Make sure the primary site is also using this URL. + +### Enable secondary proxying + +1. SSH into each application node (serving user traffic directly) on your secondary Geo site + and add the following environment variable: + + ```shell + sudo editor /etc/gitlab/gitlab.rb + ``` + + ```ruby + gitlab_workhorse['env'] = { + "GEO_SECONDARY_PROXY" => "1" + } + ``` + +1. Reconfigure the updated nodes for the change to take effect: + + ```shell + gitlab-ctl reconfigure + ``` + +1. SSH into one node running Rails on your primary Geo site and enable the Geo secondary proxy feature flag: + + ```shell + sudo gitlab-rails runner "Feature.enable(:geo_secondary_proxy)" + ``` + +## Enable Geo proxying with Separate URLs + +The ability to use proxying with separate URLs is still in development. You can follow the +["Geo secondary proxying with separate URLs" epic](https://gitlab.com/groups/gitlab-org/-/epics/6865) +for progress. + +## Features accelerated by secondary Geo sites + +Most HTTP traffic sent to a secondary Geo site can be proxied to the primary Geo site. With this architecture, +secondary Geo sites are able to support write requests. Certain requests are handled locally by secondary +sites for improved latency and bandwidth nearby. + +The following table details the components currently tested through the Geo secondary site Workhorse proxy. +It does not cover all data types, more will be added in the future as they are tested. + +| Feature / component | Proxied? | +|:----------------------------------------------------|:-----------------------| +| Project, wiki, design repository (using the web UI) | **{check-circle}** Yes | +| Project, wiki repository (using Git) | **{dotted-circle}** Partly <sup>1</sup> | +| Project, Personal Snippet (using the web UI) | **{check-circle}** Yes | +| Project, Personal Snippet (using Git) | **{dotted-circle}** Partly <sup>1</sup> | +| Group wiki repository (using the web UI) | **{check-circle}** Yes | +| Group wiki repository (using Git) | **{dotted-circle}** Partly <sup>1</sup> | +| User uploads | **{check-circle}** Yes | +| LFS objects (using the web UI) | **{check-circle}** Yes | +| LFS objects (using Git) | **{check-circle}** Yes | +| Pages | **{dotted-circle}** No <sup>2</sup> | +| Advanced search (using the web UI) | **{check-circle}** Yes | + +1. Git reads are served from the local secondary while pushes get proxied to the primary. + Selective sync or cases where repositories don't exist locally on the Geo secondary throw a "not found" error. +1. Pages can use the same URL (without access control), but must be configured separately and are not proxied. diff --git a/doc/administration/geo/secondary_proxy/location_aware_external_url.md b/doc/administration/geo/secondary_proxy/location_aware_external_url.md new file mode 100644 index 00000000000..b5a8d4baa1f --- /dev/null +++ b/doc/administration/geo/secondary_proxy/location_aware_external_url.md @@ -0,0 +1,83 @@ +--- +stage: Enablement +group: Geo +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments +type: howto +--- + +# Location-aware public URL **(PREMIUM SELF)** + +With [Geo proxying for secondary sites](index.md), you can provide GitLab users +with a single URL that automatically uses the Geo site closest to them. +Users don't need to use different URLs or worry about read-only operations to take +advantage of closer Geo sites as they move. + +With [Geo proxying for secondary sites](index.md) web and Git requests are proxied +from **secondary** sites to the **primary**. + +Though these instructions use [AWS Route53](https://aws.amazon.com/route53/), +other services such as [Cloudflare](https://www.cloudflare.com/) can be used +as well. + +## Prerequisites + +This example creates a `gitlab.example.com` subdomain that automatically directs +requests: + +- From Europe to a **secondary** site. +- From all other locations to the **primary** site. + +The URLs to access each node by itself are: + +- `primary.example.com` as a Geo **primary** site. +- `secondary.example.com` as a Geo **secondary** site. + +For this example, you need: + +- A working GitLab **primary** site that is accessible at `gitlab.example.com` _and_ `primary.example.com`. +- A working GitLab **secondary** site. +- A Route53 Hosted Zone managing your domain for the Route53 setup. + +If you haven't yet set up a Geo _primary_ site and _secondary_ site, see the +[Geo setup instructions](../index.md#setup-instructions). + +## AWS Route53 + +### Create a traffic policy + +In a Route53 Hosted Zone, traffic policies can be used to set up a variety of +routing configurations. + +1. Go to the +[Route53 dashboard](https://console.aws.amazon.com/route53/home) and select +**Traffic policies**. + +1. Select **Create traffic policy**. +1. Fill in the **Policy Name** field with `Single Git Host` and select **Next**. +1. Leave **DNS type** as `A: IP Address in IPv4 format`. +1. Select **Connect to...**, then **Geolocation rule**. +1. For the first **Location**: + 1. Leave it as `Default`. + 1. Select **Connect to...**, then **New endpoint**. + 1. Choose **Type** `value` and fill it in with `<your **primary** IP address>`. + +1. For the second **Location**: + 1. Choose `Europe`. + 1. Select **Connect to...** and select **New endpoint**. + 1. Choose **Type** `value` and fill it in with `<your **secondary** IP address>`. + + ![Add traffic policy endpoints](img/single_url_add_traffic_policy_endpoints.png) + +1. Select **Create traffic policy**. +1. Fill in **Policy record DNS name** with `gitlab`. + + ![Create policy records with traffic policy](img/single_url_create_policy_records_with_traffic_policy.png) + +1. Select **Create policy records**. + +You have successfully set up a single host, like `gitlab.example.com`, which +distributes traffic to your Geo sites by geolocation. + +## Enable Geo proxying for secondary sites + +After setting up a single URL to use for all Geo sites, continue with the [steps to enable Geo proxying for secondary sites](index.md). diff --git a/lib/gitlab/email/message/in_product_marketing.rb b/lib/gitlab/email/message/in_product_marketing.rb index fb4315e74b2..8843dafb533 100644 --- a/lib/gitlab/email/message/in_product_marketing.rb +++ b/lib/gitlab/email/message/in_product_marketing.rb @@ -7,7 +7,8 @@ module Gitlab UnknownTrackError = Class.new(StandardError) def self.for(track) - raise UnknownTrackError unless Namespaces::InProductMarketingEmailsService::TRACKS.key?(track) + valid_tracks = [:invite_team, Namespaces::InProductMarketingEmailsService::TRACKS.keys].flatten + raise UnknownTrackError unless valid_tracks.include?(track) "Gitlab::Email::Message::InProductMarketing::#{track.to_s.classify}".constantize end diff --git a/lib/gitlab/email/message/in_product_marketing/base.rb b/lib/gitlab/email/message/in_product_marketing/base.rb index 8c6583164bf..07e6c65ed0f 100644 --- a/lib/gitlab/email/message/in_product_marketing/base.rb +++ b/lib/gitlab/email/message/in_product_marketing/base.rb @@ -12,12 +12,12 @@ module Gitlab attr_accessor :format def initialize(group:, user:, series:, format: :html) - raise ArgumentError, "Only #{total_series} series available for this track." unless series.between?(0, total_series - 1) - + @series = series @group = group @user = user - @series = series @format = format + + validate_series! end def subject_line @@ -115,6 +115,10 @@ module Gitlab ["mailers/in_product_marketing", "#{track}-#{series}.png"].join('/') end + def series? + total_series > 0 + end + protected attr_reader :group, :user, :series @@ -171,6 +175,12 @@ module Gitlab e.run end end + + def validate_series! + return unless series? + + raise ArgumentError, "Only #{total_series} series available for this track." unless @series.between?(0, total_series - 1) + end end end end diff --git a/lib/gitlab/email/message/in_product_marketing/invite_team.rb b/lib/gitlab/email/message/in_product_marketing/invite_team.rb new file mode 100644 index 00000000000..5db0660dc14 --- /dev/null +++ b/lib/gitlab/email/message/in_product_marketing/invite_team.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Gitlab + module Email + module Message + module InProductMarketing + class InviteTeam < Base + def subject_line + s_('InProductMarketing|Invite your teammates to GitLab') + end + + def tagline + '' + end + + def title + s_('InProductMarketing|GitLab is better with teammates to help out!') + end + + def subtitle + '' + end + + def body_line1 + s_('InProductMarketing|Invite your teammates today and build better code together. You can even assign tasks to new teammates such as setting up CI/CD, to help get projects up and running.') + end + + def body_line2 + '' + end + + def cta_text + s_('InProductMarketing|Invite your teammates to help') + end + + def logo_path + 'mailers/in_product_marketing/team-0.png' + end + + def series? + false + end + end + end + end + end +end diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb index 0d469c8b752..df3760b4c51 100644 --- a/lib/gitlab/usage_data.rb +++ b/lib/gitlab/usage_data.rb @@ -536,7 +536,9 @@ module Gitlab # rubocop: disable CodeReuse/ActiveRecord def usage_activity_by_stage_manage(time_period) { - events: distinct_count(::Event.where(time_period), :author_id), + # rubocop: disable UsageData/LargeTable + events: estimate_batch_distinct_count(::Event.where(time_period), :author_id), + # rubocop: enable UsageData/LargeTable groups: distinct_count(::GroupMember.where(time_period), :user_id), users_created: count(::User.where(time_period), start: minimum_id(User), finish: maximum_id(User)), omniauth_providers: filtered_omniauth_provider_names.reject { |name| name == 'group_saml' }, diff --git a/locale/gitlab.pot b/locale/gitlab.pot index c6c00bedca3..3f5d09ea5c3 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -17604,6 +17604,9 @@ msgstr "" msgid "InProductMarketing|GitHub Enterprise projects to GitLab" msgstr "" +msgid "InProductMarketing|GitLab is better with teammates to help out!" +msgstr "" + msgid "InProductMarketing|GitLab provides static application security testing (SAST), dynamic application security testing (DAST), container scanning, and dependency scanning to help you deliver secure applications along with license compliance." msgstr "" @@ -17679,6 +17682,15 @@ msgstr "" msgid "InProductMarketing|Invite your team today to build better code (and processes) together" msgstr "" +msgid "InProductMarketing|Invite your teammates to GitLab" +msgstr "" + +msgid "InProductMarketing|Invite your teammates to help" +msgstr "" + +msgid "InProductMarketing|Invite your teammates today and build better code together. You can even assign tasks to new teammates such as setting up CI/CD, to help get projects up and running." +msgstr "" + msgid "InProductMarketing|It's all in the stats" msgstr "" diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb index 10c1c2cb26e..66ebd00d368 100644 --- a/spec/features/users/login_spec.rb +++ b/spec/features/users/login_spec.rb @@ -753,7 +753,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_shared_state do end end - context 'when terms are enforced' do + context 'when terms are enforced', :js do let(:user) { create(:user) } before do @@ -802,7 +802,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_shared_state do end context 'when the user did not enable 2FA' do - it 'asks to set 2FA before asking to accept the terms', :js do + it 'asks to set 2FA before asking to accept the terms' do expect(authentication_metrics) .to increment(:user_authenticated_counter) @@ -887,7 +887,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_shared_state do end end - context 'when the user does not have an email configured', :js do + context 'when the user does not have an email configured' do let(:user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'saml', email: 'temp-email-for-oauth-user@gitlab.localhost') } before do diff --git a/spec/features/users/terms_spec.rb b/spec/features/users/terms_spec.rb index 8ba79d77c22..7cfe74f8aa9 100644 --- a/spec/features/users/terms_spec.rb +++ b/spec/features/users/terms_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Users > Terms' do +RSpec.describe 'Users > Terms', :js do include TermsHelper let!(:term) { create(:term, terms: 'By accepting, you promise to be nice!') } diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js index 96e5202780b..f7bde8d2f16 100644 --- a/spec/frontend/flash_spec.js +++ b/spec/frontend/flash_spec.js @@ -3,6 +3,7 @@ import createFlash, { createAction, hideFlash, removeFlashClickListener, + FLASH_CLOSED_EVENT, } from '~/flash'; describe('Flash', () => { @@ -79,6 +80,16 @@ describe('Flash', () => { expect(el.remove.mock.calls.length).toBe(1); }); + + it(`dispatches ${FLASH_CLOSED_EVENT} event after transitionend event`, () => { + jest.spyOn(el, 'dispatchEvent'); + + hideFlash(el); + + el.dispatchEvent(new Event('transitionend')); + + expect(el.dispatchEvent).toHaveBeenCalledWith(new Event(FLASH_CLOSED_EVENT)); + }); }); describe('createAction', () => { diff --git a/spec/frontend/integrations/integration_settings_form_spec.js b/spec/frontend/integrations/integration_settings_form_spec.js index f8f3f0fd318..c35d178e518 100644 --- a/spec/frontend/integrations/integration_settings_form_spec.js +++ b/spec/frontend/integrations/integration_settings_form_spec.js @@ -1,27 +1,38 @@ import MockAdaptor from 'axios-mock-adapter'; import IntegrationSettingsForm from '~/integrations/integration_settings_form'; +import eventHub from '~/integrations/edit/event_hub'; import axios from '~/lib/utils/axios_utils'; import toast from '~/vue_shared/plugins/global_toast'; +import { + I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE, + I18N_SUCCESSFUL_CONNECTION_MESSAGE, + I18N_DEFAULT_ERROR_MESSAGE, + GET_JIRA_ISSUE_TYPES_EVENT, + TOGGLE_INTEGRATION_EVENT, + TEST_INTEGRATION_EVENT, + SAVE_INTEGRATION_EVENT, +} from '~/integrations/constants'; +import waitForPromises from 'helpers/wait_for_promises'; jest.mock('~/vue_shared/plugins/global_toast'); +jest.mock('lodash/delay', () => (callback) => callback()); + +const FIXTURE = 'services/edit_service.html'; describe('IntegrationSettingsForm', () => { - const FIXTURE = 'services/edit_service.html'; + let integrationSettingsForm; + + const mockStoreDispatch = () => jest.spyOn(integrationSettingsForm.vue.$store, 'dispatch'); beforeEach(() => { loadFixtures(FIXTURE); + + integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); + integrationSettingsForm.init(); }); describe('constructor', () => { - let integrationSettingsForm; - - beforeEach(() => { - integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); - jest.spyOn(integrationSettingsForm, 'init').mockImplementation(() => {}); - }); - it('should initialize form element refs on class object', () => { - // Form Reference expect(integrationSettingsForm.$form).toBeDefined(); expect(integrationSettingsForm.$form.nodeName).toBe('FORM'); expect(integrationSettingsForm.formActive).toBeDefined(); @@ -32,180 +43,206 @@ describe('IntegrationSettingsForm', () => { }); }); - describe('toggleServiceState', () => { - let integrationSettingsForm; + describe('event handling', () => { + let mockAxios; beforeEach(() => { - integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); - }); - - it('should remove `novalidate` attribute to form when called with `true`', () => { - integrationSettingsForm.formActive = true; - integrationSettingsForm.toggleServiceState(); - - expect(integrationSettingsForm.$form.getAttribute('novalidate')).toBe(null); - }); - - it('should set `novalidate` attribute to form when called with `false`', () => { - integrationSettingsForm.formActive = false; - integrationSettingsForm.toggleServiceState(); - - expect(integrationSettingsForm.$form.getAttribute('novalidate')).toBeDefined(); - }); - }); - - describe('testSettings', () => { - let integrationSettingsForm; - let formData; - let mock; - - beforeEach(() => { - mock = new MockAdaptor(axios); - + mockAxios = new MockAdaptor(axios); jest.spyOn(axios, 'put'); - - integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); - integrationSettingsForm.init(); - - formData = new FormData(integrationSettingsForm.$form); }); afterEach(() => { - mock.restore(); + mockAxios.restore(); + eventHub.dispose(); // clear event hub handlers }); - it('should make an ajax request with provided `formData`', async () => { - await integrationSettingsForm.testSettings(formData); + describe('when event hub receives `TOGGLE_INTEGRATION_EVENT`', () => { + it('should remove `novalidate` attribute to form when called with `true`', () => { + eventHub.$emit(TOGGLE_INTEGRATION_EVENT, true); - expect(axios.put).toHaveBeenCalledWith(integrationSettingsForm.testEndPoint, formData); - }); + expect(integrationSettingsForm.$form.getAttribute('novalidate')).toBe(null); + }); - it('should show success message if test is successful', async () => { - jest.spyOn(integrationSettingsForm.$form, 'submit').mockImplementation(() => {}); + it('should set `novalidate` attribute to form when called with `false`', () => { + eventHub.$emit(TOGGLE_INTEGRATION_EVENT, false); - mock.onPut(integrationSettingsForm.testEndPoint).reply(200, { - error: false, + expect(integrationSettingsForm.$form.getAttribute('novalidate')).toBe('novalidate'); }); + }); - await integrationSettingsForm.testSettings(formData); + describe('when event hub receives `TEST_INTEGRATION_EVENT`', () => { + describe('when form is valid', () => { + beforeEach(() => { + jest.spyOn(integrationSettingsForm.$form, 'checkValidity').mockReturnValue(true); + }); - expect(toast).toHaveBeenCalledWith('Connection successful.'); - }); + it('should make an ajax request with provided `formData`', async () => { + eventHub.$emit(TEST_INTEGRATION_EVENT); + await waitForPromises(); - it('should show error message if ajax request responds with test error', async () => { - const errorMessage = 'Test failed.'; - const serviceResponse = 'some error'; + expect(axios.put).toHaveBeenCalledWith( + integrationSettingsForm.testEndPoint, + new FormData(integrationSettingsForm.$form), + ); + }); - mock.onPut(integrationSettingsForm.testEndPoint).reply(200, { - error: true, - message: errorMessage, - service_response: serviceResponse, - test_failed: false, - }); + it('should show success message if test is successful', async () => { + jest.spyOn(integrationSettingsForm.$form, 'submit').mockImplementation(() => {}); - await integrationSettingsForm.testSettings(formData); + mockAxios.onPut(integrationSettingsForm.testEndPoint).reply(200, { + error: false, + }); - expect(toast).toHaveBeenCalledWith(`${errorMessage} ${serviceResponse}`); - }); + eventHub.$emit(TEST_INTEGRATION_EVENT); + await waitForPromises(); - it('should show error message if ajax request failed', async () => { - const errorMessage = 'Something went wrong on our end.'; + expect(toast).toHaveBeenCalledWith(I18N_SUCCESSFUL_CONNECTION_MESSAGE); + }); - mock.onPut(integrationSettingsForm.testEndPoint).networkError(); + it('should show error message if ajax request responds with test error', async () => { + const errorMessage = 'Test failed.'; + const serviceResponse = 'some error'; - await integrationSettingsForm.testSettings(formData); + mockAxios.onPut(integrationSettingsForm.testEndPoint).reply(200, { + error: true, + message: errorMessage, + service_response: serviceResponse, + test_failed: false, + }); - expect(toast).toHaveBeenCalledWith(errorMessage); - }); + eventHub.$emit(TEST_INTEGRATION_EVENT); + await waitForPromises(); - it('should always dispatch `setIsTesting` with `false` once request is completed', async () => { - const dispatchSpy = jest.fn(); + expect(toast).toHaveBeenCalledWith(`${errorMessage} ${serviceResponse}`); + }); - mock.onPut(integrationSettingsForm.testEndPoint).networkError(); + it('should show error message if ajax request failed', async () => { + mockAxios.onPut(integrationSettingsForm.testEndPoint).networkError(); - integrationSettingsForm.vue.$store = { dispatch: dispatchSpy }; + eventHub.$emit(TEST_INTEGRATION_EVENT); + await waitForPromises(); - await integrationSettingsForm.testSettings(formData); + expect(toast).toHaveBeenCalledWith(I18N_DEFAULT_ERROR_MESSAGE); + }); - expect(dispatchSpy).toHaveBeenCalledWith('setIsTesting', false); - }); - }); + it('should always dispatch `setIsTesting` with `false` once request is completed', async () => { + const dispatchSpy = mockStoreDispatch(); + mockAxios.onPut(integrationSettingsForm.testEndPoint).networkError(); - describe('getJiraIssueTypes', () => { - let integrationSettingsForm; - let formData; - let mock; + eventHub.$emit(TEST_INTEGRATION_EVENT); + await waitForPromises(); - beforeEach(() => { - mock = new MockAdaptor(axios); + expect(dispatchSpy).toHaveBeenCalledWith('setIsTesting', false); + }); + }); - jest.spyOn(axios, 'put'); + describe('when form is invalid', () => { + beforeEach(() => { + jest.spyOn(integrationSettingsForm.$form, 'checkValidity').mockReturnValue(false); + jest.spyOn(integrationSettingsForm, 'testSettings'); + }); - integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); - integrationSettingsForm.init(); + it('should dispatch `setIsTesting` with `false` and not call `testSettings`', async () => { + const dispatchSpy = mockStoreDispatch(); - formData = new FormData(integrationSettingsForm.$form); - }); + eventHub.$emit(TEST_INTEGRATION_EVENT); + await waitForPromises(); - afterEach(() => { - mock.restore(); + expect(dispatchSpy).toHaveBeenCalledWith('setIsTesting', false); + expect(integrationSettingsForm.testSettings).not.toHaveBeenCalled(); + }); + }); }); - it('should always dispatch `requestJiraIssueTypes`', async () => { - const dispatchSpy = jest.fn(); - - mock.onPut(integrationSettingsForm.testEndPoint).networkError(); + describe('when event hub receives `GET_JIRA_ISSUE_TYPES_EVENT`', () => { + it('should always dispatch `requestJiraIssueTypes`', () => { + const dispatchSpy = mockStoreDispatch(); + mockAxios.onPut(integrationSettingsForm.testEndPoint).networkError(); - integrationSettingsForm.vue.$store = { dispatch: dispatchSpy }; + eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT); - await integrationSettingsForm.getJiraIssueTypes(); + expect(dispatchSpy).toHaveBeenCalledWith('requestJiraIssueTypes'); + }); - expect(dispatchSpy).toHaveBeenCalledWith('requestJiraIssueTypes'); - }); + it('should make an ajax request with provided `formData`', () => { + eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT); - it('should make an ajax request with provided `formData`', async () => { - await integrationSettingsForm.getJiraIssueTypes(formData); + expect(axios.put).toHaveBeenCalledWith( + integrationSettingsForm.testEndPoint, + new FormData(integrationSettingsForm.$form), + ); + }); - expect(axios.put).toHaveBeenCalledWith(integrationSettingsForm.testEndPoint, formData); - }); + it('should dispatch `receiveJiraIssueTypesSuccess` with the correct payload if ajax request is successful', async () => { + const dispatchSpy = mockStoreDispatch(); + const mockData = ['ISSUE', 'EPIC']; + mockAxios.onPut(integrationSettingsForm.testEndPoint).reply(200, { + error: false, + issuetypes: mockData, + }); - it('should dispatch `receiveJiraIssueTypesSuccess` with the correct payload if ajax request is successful', async () => { - const mockData = ['ISSUE', 'EPIC']; - const dispatchSpy = jest.fn(); + eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT); + await waitForPromises(); - mock.onPut(integrationSettingsForm.testEndPoint).reply(200, { - error: false, - issuetypes: mockData, + expect(dispatchSpy).toHaveBeenCalledWith('receiveJiraIssueTypesSuccess', mockData); }); - integrationSettingsForm.vue.$store = { dispatch: dispatchSpy }; - - await integrationSettingsForm.getJiraIssueTypes(formData); + it.each(['Custom error message here', undefined])( + 'should dispatch "receiveJiraIssueTypesError" with a message if the backend responds with error', + async (responseErrorMessage) => { + const dispatchSpy = mockStoreDispatch(); + + const expectedErrorMessage = + responseErrorMessage || I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE; + mockAxios.onPut(integrationSettingsForm.testEndPoint).reply(200, { + error: true, + message: responseErrorMessage, + }); + + eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT); + await waitForPromises(); + + expect(dispatchSpy).toHaveBeenCalledWith( + 'receiveJiraIssueTypesError', + expectedErrorMessage, + ); + }, + ); + }); + + describe('when event hub receives `SAVE_INTEGRATION_EVENT`', () => { + describe('when form is valid', () => { + beforeEach(() => { + jest.spyOn(integrationSettingsForm.$form, 'checkValidity').mockReturnValue(true); + jest.spyOn(integrationSettingsForm.$form, 'submit'); + }); - expect(dispatchSpy).toHaveBeenCalledWith('receiveJiraIssueTypesSuccess', mockData); - }); + it('should submit the form', async () => { + eventHub.$emit(SAVE_INTEGRATION_EVENT); + await waitForPromises(); - it.each(['something went wrong', undefined])( - 'should dispatch "receiveJiraIssueTypesError" with a message if the backend responds with error', - async (responseErrorMessage) => { - const defaultErrorMessage = 'Connection failed. Please check your settings.'; - const expectedErrorMessage = responseErrorMessage || defaultErrorMessage; - const dispatchSpy = jest.fn(); + expect(integrationSettingsForm.$form.submit).toHaveBeenCalled(); + expect(integrationSettingsForm.$form.submit).toHaveBeenCalledTimes(1); + }); + }); - mock.onPut(integrationSettingsForm.testEndPoint).reply(200, { - error: true, - message: responseErrorMessage, + describe('when form is invalid', () => { + beforeEach(() => { + jest.spyOn(integrationSettingsForm.$form, 'checkValidity').mockReturnValue(false); + jest.spyOn(integrationSettingsForm.$form, 'submit'); }); - integrationSettingsForm.vue.$store = { dispatch: dispatchSpy }; + it('should dispatch `setIsSaving` with `false` and not submit form', async () => { + const dispatchSpy = mockStoreDispatch(); - await integrationSettingsForm.getJiraIssueTypes(formData); + eventHub.$emit(SAVE_INTEGRATION_EVENT); - expect(dispatchSpy).toHaveBeenCalledWith( - 'receiveJiraIssueTypesError', - expectedErrorMessage, - ); - }, - ); + await waitForPromises(); + + expect(dispatchSpy).toHaveBeenCalledWith('setIsSaving', false); + expect(integrationSettingsForm.$form.submit).not.toHaveBeenCalled(); + }); + }); + }); }); }); diff --git a/spec/frontend/terms/components/app_spec.js b/spec/frontend/terms/components/app_spec.js new file mode 100644 index 00000000000..ee78b35843a --- /dev/null +++ b/spec/frontend/terms/components/app_spec.js @@ -0,0 +1,171 @@ +import $ from 'jquery'; +import { merge } from 'lodash'; +import { GlIntersectionObserver } from '@gitlab/ui'; +import { nextTick } from 'vue'; + +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { FLASH_TYPES, FLASH_CLOSED_EVENT } from '~/flash'; +import { isLoggedIn } from '~/lib/utils/common_utils'; +import TermsApp from '~/terms/components/app.vue'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); +jest.mock('~/lib/utils/common_utils'); + +describe('TermsApp', () => { + let wrapper; + let renderGFMSpy; + + const defaultProvide = { + terms: 'foo bar', + paths: { + accept: '/-/users/terms/1/accept', + decline: '/-/users/terms/1/decline', + root: '/', + }, + permissions: { + canAccept: true, + canDecline: true, + }, + }; + + const createComponent = (provide = {}) => { + wrapper = mountExtended(TermsApp, { + provide: merge({}, defaultProvide, provide), + }); + }; + + beforeEach(() => { + renderGFMSpy = jest.spyOn($.fn, 'renderGFM'); + isLoggedIn.mockReturnValue(true); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findFormWithAction = (path) => wrapper.find(`form[action="${path}"]`); + const findButton = (path) => findFormWithAction(path).find('button[type="submit"]'); + const findScrollableViewport = () => wrapper.findByTestId('scrollable-viewport'); + + const expectFormWithSubmitButton = (buttonText, path) => { + const form = findFormWithAction(path); + const submitButton = findButton(path); + + expect(form.exists()).toBe(true); + expect(submitButton.exists()).toBe(true); + expect(submitButton.text()).toBe(buttonText); + expect( + form + .find('input[type="hidden"][name="authenticity_token"][value="mock-csrf-token"]') + .exists(), + ).toBe(true); + }; + + it('renders terms of service as markdown', () => { + createComponent(); + + expect(wrapper.findByText(defaultProvide.terms).exists()).toBe(true); + expect(renderGFMSpy).toHaveBeenCalled(); + }); + + describe('accept button', () => { + it('is disabled until user scrolls to the bottom of the terms', async () => { + createComponent(); + + expect(findButton(defaultProvide.paths.accept).attributes('disabled')).toBe('disabled'); + + wrapper.find(GlIntersectionObserver).vm.$emit('appear'); + + await nextTick(); + + expect(findButton(defaultProvide.paths.accept).attributes('disabled')).toBeUndefined(); + }); + + describe('when user has permissions to accept', () => { + it('renders form and button to accept terms', () => { + createComponent(); + + expectFormWithSubmitButton(TermsApp.i18n.accept, defaultProvide.paths.accept); + }); + }); + + describe('when user does not have permissions to accept', () => { + it('renders continue button', () => { + createComponent({ permissions: { canAccept: false } }); + + expect(wrapper.findByText(TermsApp.i18n.continue).exists()).toBe(true); + }); + }); + }); + + describe('decline button', () => { + describe('when user has permissions to decline', () => { + it('renders form and button to decline terms', () => { + createComponent(); + + expectFormWithSubmitButton(TermsApp.i18n.decline, defaultProvide.paths.decline); + }); + }); + + describe('when user does not have permissions to decline', () => { + it('does not render decline button', () => { + createComponent({ permissions: { canDecline: false } }); + + expect(wrapper.findByText(TermsApp.i18n.decline).exists()).toBe(false); + }); + }); + }); + + it('sets height of scrollable viewport', () => { + jest.spyOn(document.documentElement, 'scrollHeight', 'get').mockImplementation(() => 800); + jest.spyOn(document.documentElement, 'clientHeight', 'get').mockImplementation(() => 600); + + createComponent(); + + expect(findScrollableViewport().attributes('style')).toBe('max-height: calc(100vh - 200px);'); + }); + + describe('when flash is closed', () => { + let flashEl; + + beforeEach(() => { + flashEl = document.createElement('div'); + flashEl.classList.add(`flash-${FLASH_TYPES.ALERT}`); + document.body.appendChild(flashEl); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('recalculates height of scrollable viewport', () => { + jest.spyOn(document.documentElement, 'scrollHeight', 'get').mockImplementation(() => 800); + jest.spyOn(document.documentElement, 'clientHeight', 'get').mockImplementation(() => 600); + + createComponent(); + + expect(findScrollableViewport().attributes('style')).toBe('max-height: calc(100vh - 200px);'); + + jest.spyOn(document.documentElement, 'scrollHeight', 'get').mockImplementation(() => 700); + jest.spyOn(document.documentElement, 'clientHeight', 'get').mockImplementation(() => 600); + + flashEl.dispatchEvent(new Event(FLASH_CLOSED_EVENT)); + + expect(findScrollableViewport().attributes('style')).toBe('max-height: calc(100vh - 100px);'); + }); + }); + + describe('when user is signed out', () => { + beforeEach(() => { + isLoggedIn.mockReturnValue(false); + }); + + it('does not show any buttons', () => { + createComponent(); + + expect(wrapper.findByText(TermsApp.i18n.accept).exists()).toBe(false); + expect(wrapper.findByText(TermsApp.i18n.decline).exists()).toBe(false); + expect(wrapper.findByText(TermsApp.i18n.continue).exists()).toBe(false); + }); + }); +}); diff --git a/spec/helpers/terms_helper_spec.rb b/spec/helpers/terms_helper_spec.rb new file mode 100644 index 00000000000..9120aad4627 --- /dev/null +++ b/spec/helpers/terms_helper_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe TermsHelper do + let_it_be(:current_user) { build(:user) } + let_it_be(:terms) { build(:term) } + + before do + allow(helper).to receive(:current_user).and_return(current_user) + end + + describe '#terms_data' do + let_it_be(:redirect) { '%2F' } + let_it_be(:terms_markdown) { 'Lorem ipsum dolor sit amet' } + let_it_be(:accept_path) { '/-/users/terms/14/accept?redirect=%2F' } + let_it_be(:decline_path) { '/-/users/terms/14/decline?redirect=%2F' } + + subject(:result) { Gitlab::Json.parse(helper.terms_data(terms, redirect)) } + + it 'returns correct json' do + expect(helper).to receive(:markdown_field).with(terms, :terms).and_return(terms_markdown) + expect(helper).to receive(:can?).with(current_user, :accept_terms, terms).and_return(true) + expect(helper).to receive(:can?).with(current_user, :decline_terms, terms).and_return(true) + expect(helper).to receive(:accept_term_path).with(terms, { redirect: redirect }).and_return(accept_path) + expect(helper).to receive(:decline_term_path).with(terms, { redirect: redirect }).and_return(decline_path) + + expected = { + terms: terms_markdown, + permissions: { + can_accept: true, + can_decline: true + }, + paths: { + accept: accept_path, + decline: decline_path, + root: root_path + } + }.as_json + + expect(result).to eq(expected) + end + end +end diff --git a/spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb b/spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb index 277f1158f8b..0521123f1ef 100644 --- a/spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb +++ b/spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb @@ -82,4 +82,29 @@ RSpec.describe Gitlab::Email::Message::InProductMarketing::Base do it { is_expected.to include('This is email 1 of 3 in the Create series', Gitlab::Routing.url_helpers.profile_notifications_url) } end end + + describe '#series?' do + using RSpec::Parameterized::TableSyntax + + subject do + test_class = "Gitlab::Email::Message::InProductMarketing::#{track.to_s.classify}".constantize + test_class.new(group: group, user: user, series: series).series? + end + + where(:track, :result) do + :create | true + :team_short | true + :trial_short | true + :admin_verify | true + :verify | true + :trial | true + :team | true + :experience | true + :invite_team | false + end + + with_them do + it { is_expected.to eq result } + end + end end diff --git a/spec/lib/gitlab/email/message/in_product_marketing/invite_team_spec.rb b/spec/lib/gitlab/email/message/in_product_marketing/invite_team_spec.rb new file mode 100644 index 00000000000..13377ee0cad --- /dev/null +++ b/spec/lib/gitlab/email/message/in_product_marketing/invite_team_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Email::Message::InProductMarketing::InviteTeam do + let_it_be(:group) { build(:group) } + let_it_be(:user) { build(:user) } + + subject(:message) { described_class.new(group: group, user: user, series: 0) } + + it 'contains the correct message', :aggregate_failures do + expect(message.subject_line).to eq 'Invite your teammates to GitLab' + expect(message.tagline).to be_empty + expect(message.title).to eq 'GitLab is better with teammates to help out!' + expect(message.subtitle).to be_empty + expect(message.body_line1).to eq 'Invite your teammates today and build better code together. You can even assign tasks to new teammates such as setting up CI/CD, to help get projects up and running.' + expect(message.body_line2).to be_empty + expect(message.cta_text).to eq 'Invite your teammates to help' + expect(message.logo_path).to eq 'mailers/in_product_marketing/team-0.png' + end +end diff --git a/spec/lib/gitlab/email/message/in_product_marketing_spec.rb b/spec/lib/gitlab/email/message/in_product_marketing_spec.rb index 9ffc4a340a3..594df7440bb 100644 --- a/spec/lib/gitlab/email/message/in_product_marketing_spec.rb +++ b/spec/lib/gitlab/email/message/in_product_marketing_spec.rb @@ -10,10 +10,15 @@ RSpec.describe Gitlab::Email::Message::InProductMarketing do context 'when track exists' do where(:track, :expected_class) do - :create | described_class::Create - :verify | described_class::Verify - :trial | described_class::Trial - :team | described_class::Team + :create | described_class::Create + :team_short | described_class::TeamShort + :trial_short | described_class::TrialShort + :admin_verify | described_class::AdminVerify + :verify | described_class::Verify + :trial | described_class::Trial + :team | described_class::Team + :experience | described_class::Experience + :invite_team | described_class::InviteTeam end with_them do diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index ae1c0eceadb..dc4eafbd63f 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -193,6 +193,8 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do end describe 'usage_activity_by_stage_manage' do + let_it_be(:error_rate) { Gitlab::Database::PostgresHll::BatchDistinctCounter::ERROR_RATE } + it 'includes accurate usage_activity_by_stage data' do stub_config( omniauth: @@ -213,14 +215,14 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do end expect(described_class.usage_activity_by_stage_manage({})).to include( - events: 2, + events: be_within(error_rate).percent_of(2), groups: 2, users_created: 6, omniauth_providers: ['google_oauth2'], user_auth_by_provider: { 'group_saml' => 2, 'ldap' => 4, 'standard' => 0, 'two-factor' => 0, 'two-factor-via-u2f-device' => 0, "two-factor-via-webauthn-device" => 0 } ) expect(described_class.usage_activity_by_stage_manage(described_class.monthly_time_range_db_params)).to include( - events: 1, + events: be_within(error_rate).percent_of(1), groups: 1, users_created: 3, omniauth_providers: ['google_oauth2'], diff --git a/spec/mailers/emails/in_product_marketing_spec.rb b/spec/mailers/emails/in_product_marketing_spec.rb index c4ec4c84ff4..3b92b049e42 100644 --- a/spec/mailers/emails/in_product_marketing_spec.rb +++ b/spec/mailers/emails/in_product_marketing_spec.rb @@ -63,6 +63,7 @@ RSpec.describe Emails::InProductMarketing do :team_short | 0 :trial_short | 0 :admin_verify | 0 + :invite_team | 0 end with_them do @@ -92,6 +93,12 @@ RSpec.describe Emails::InProductMarketing do is_expected.not_to have_body_text(message.invite_text) is_expected.not_to have_body_text(CGI.unescapeHTML(message.invite_link)) end + + if track == :invite_team + is_expected.not_to have_body_text(/This is email \d of \d/) + else + is_expected.to have_body_text(message.progress) + end end end end |