Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-10-26 12:12:11 +00:00
parent e48c28ed86
commit 0594381ba7
38 changed files with 1033 additions and 223 deletions

View File

@ -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,
};

View File

@ -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.');

View File

@ -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);

View File

@ -0,0 +1,4 @@
import { initTermsApp } from '~/terms';
import { waitForCSSLoaded } from '~/helpers/startup_css_helper';
waitForCSSLoaded(initTermsApp);

View File

@ -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>

View File

@ -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);
},
});
};

View File

@ -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: {

View File

@ -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,
},

View File

@ -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';

View File

@ -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;

View File

@ -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

View File

@ -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'

View File

@ -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?

View File

@ -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;' })

View File

@ -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')

View File

@ -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"

View File

@ -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

View File

@ -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).

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -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.

View File

@ -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).

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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' },

View File

@ -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 ""

View File

@ -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

View File

@ -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!') }

View File

@ -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', () => {

View File

@ -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);
});
it('should show success message if test is successful', async () => {
jest.spyOn(integrationSettingsForm.$form, 'submit').mockImplementation(() => {});
mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
error: false,
expect(integrationSettingsForm.$form.getAttribute('novalidate')).toBe(null);
});
await integrationSettingsForm.testSettings(formData);
it('should set `novalidate` attribute to form when called with `false`', () => {
eventHub.$emit(TOGGLE_INTEGRATION_EVENT, false);
expect(toast).toHaveBeenCalledWith('Connection successful.');
});
it('should show error message if ajax request responds with test error', async () => {
const errorMessage = 'Test failed.';
const serviceResponse = 'some error';
mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
error: true,
message: errorMessage,
service_response: serviceResponse,
test_failed: false,
expect(integrationSettingsForm.$form.getAttribute('novalidate')).toBe('novalidate');
});
await integrationSettingsForm.testSettings(formData);
expect(toast).toHaveBeenCalledWith(`${errorMessage} ${serviceResponse}`);
});
it('should show error message if ajax request failed', async () => {
const errorMessage = 'Something went wrong on our end.';
mock.onPut(integrationSettingsForm.testEndPoint).networkError();
await integrationSettingsForm.testSettings(formData);
expect(toast).toHaveBeenCalledWith(errorMessage);
});
it('should always dispatch `setIsTesting` with `false` once request is completed', async () => {
const dispatchSpy = jest.fn();
mock.onPut(integrationSettingsForm.testEndPoint).networkError();
integrationSettingsForm.vue.$store = { dispatch: dispatchSpy };
await integrationSettingsForm.testSettings(formData);
expect(dispatchSpy).toHaveBeenCalledWith('setIsTesting', false);
});
});
describe('getJiraIssueTypes', () => {
let integrationSettingsForm;
let formData;
let mock;
beforeEach(() => {
mock = new MockAdaptor(axios);
jest.spyOn(axios, 'put');
integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
integrationSettingsForm.init();
formData = new FormData(integrationSettingsForm.$form);
});
afterEach(() => {
mock.restore();
});
it('should always dispatch `requestJiraIssueTypes`', async () => {
const dispatchSpy = jest.fn();
mock.onPut(integrationSettingsForm.testEndPoint).networkError();
integrationSettingsForm.vue.$store = { dispatch: dispatchSpy };
await integrationSettingsForm.getJiraIssueTypes();
expect(dispatchSpy).toHaveBeenCalledWith('requestJiraIssueTypes');
});
it('should make an ajax request with provided `formData`', async () => {
await integrationSettingsForm.getJiraIssueTypes(formData);
expect(axios.put).toHaveBeenCalledWith(integrationSettingsForm.testEndPoint, formData);
});
it('should dispatch `receiveJiraIssueTypesSuccess` with the correct payload if ajax request is successful', async () => {
const mockData = ['ISSUE', 'EPIC'];
const dispatchSpy = jest.fn();
mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
error: false,
issuetypes: mockData,
});
integrationSettingsForm.vue.$store = { dispatch: dispatchSpy };
await integrationSettingsForm.getJiraIssueTypes(formData);
expect(dispatchSpy).toHaveBeenCalledWith('receiveJiraIssueTypesSuccess', mockData);
});
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();
mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
error: true,
message: responseErrorMessage,
describe('when event hub receives `TEST_INTEGRATION_EVENT`', () => {
describe('when form is valid', () => {
beforeEach(() => {
jest.spyOn(integrationSettingsForm.$form, 'checkValidity').mockReturnValue(true);
});
integrationSettingsForm.vue.$store = { dispatch: dispatchSpy };
it('should make an ajax request with provided `formData`', async () => {
eventHub.$emit(TEST_INTEGRATION_EVENT);
await waitForPromises();
await integrationSettingsForm.getJiraIssueTypes(formData);
expect(axios.put).toHaveBeenCalledWith(
integrationSettingsForm.testEndPoint,
new FormData(integrationSettingsForm.$form),
);
});
expect(dispatchSpy).toHaveBeenCalledWith(
'receiveJiraIssueTypesError',
expectedErrorMessage,
it('should show success message if test is successful', async () => {
jest.spyOn(integrationSettingsForm.$form, 'submit').mockImplementation(() => {});
mockAxios.onPut(integrationSettingsForm.testEndPoint).reply(200, {
error: false,
});
eventHub.$emit(TEST_INTEGRATION_EVENT);
await waitForPromises();
expect(toast).toHaveBeenCalledWith(I18N_SUCCESSFUL_CONNECTION_MESSAGE);
});
it('should show error message if ajax request responds with test error', async () => {
const errorMessage = 'Test failed.';
const serviceResponse = 'some error';
mockAxios.onPut(integrationSettingsForm.testEndPoint).reply(200, {
error: true,
message: errorMessage,
service_response: serviceResponse,
test_failed: false,
});
eventHub.$emit(TEST_INTEGRATION_EVENT);
await waitForPromises();
expect(toast).toHaveBeenCalledWith(`${errorMessage} ${serviceResponse}`);
});
it('should show error message if ajax request failed', async () => {
mockAxios.onPut(integrationSettingsForm.testEndPoint).networkError();
eventHub.$emit(TEST_INTEGRATION_EVENT);
await waitForPromises();
expect(toast).toHaveBeenCalledWith(I18N_DEFAULT_ERROR_MESSAGE);
});
it('should always dispatch `setIsTesting` with `false` once request is completed', async () => {
const dispatchSpy = mockStoreDispatch();
mockAxios.onPut(integrationSettingsForm.testEndPoint).networkError();
eventHub.$emit(TEST_INTEGRATION_EVENT);
await waitForPromises();
expect(dispatchSpy).toHaveBeenCalledWith('setIsTesting', false);
});
});
describe('when form is invalid', () => {
beforeEach(() => {
jest.spyOn(integrationSettingsForm.$form, 'checkValidity').mockReturnValue(false);
jest.spyOn(integrationSettingsForm, 'testSettings');
});
it('should dispatch `setIsTesting` with `false` and not call `testSettings`', async () => {
const dispatchSpy = mockStoreDispatch();
eventHub.$emit(TEST_INTEGRATION_EVENT);
await waitForPromises();
expect(dispatchSpy).toHaveBeenCalledWith('setIsTesting', false);
expect(integrationSettingsForm.testSettings).not.toHaveBeenCalled();
});
});
});
describe('when event hub receives `GET_JIRA_ISSUE_TYPES_EVENT`', () => {
it('should always dispatch `requestJiraIssueTypes`', () => {
const dispatchSpy = mockStoreDispatch();
mockAxios.onPut(integrationSettingsForm.testEndPoint).networkError();
eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT);
expect(dispatchSpy).toHaveBeenCalledWith('requestJiraIssueTypes');
});
it('should make an ajax request with provided `formData`', () => {
eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT);
expect(axios.put).toHaveBeenCalledWith(
integrationSettingsForm.testEndPoint,
new FormData(integrationSettingsForm.$form),
);
},
);
});
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,
});
eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT);
await waitForPromises();
expect(dispatchSpy).toHaveBeenCalledWith('receiveJiraIssueTypesSuccess', mockData);
});
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');
});
it('should submit the form', async () => {
eventHub.$emit(SAVE_INTEGRATION_EVENT);
await waitForPromises();
expect(integrationSettingsForm.$form.submit).toHaveBeenCalled();
expect(integrationSettingsForm.$form.submit).toHaveBeenCalledTimes(1);
});
});
describe('when form is invalid', () => {
beforeEach(() => {
jest.spyOn(integrationSettingsForm.$form, 'checkValidity').mockReturnValue(false);
jest.spyOn(integrationSettingsForm.$form, 'submit');
});
it('should dispatch `setIsSaving` with `false` and not submit form', async () => {
const dispatchSpy = mockStoreDispatch();
eventHub.$emit(SAVE_INTEGRATION_EVENT);
await waitForPromises();
expect(dispatchSpy).toHaveBeenCalledWith('setIsSaving', false);
expect(integrationSettingsForm.$form.submit).not.toHaveBeenCalled();
});
});
});
});
});

View File

@ -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);
});
});
});

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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'],

View File

@ -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