Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
e48c28ed86
commit
0594381ba7
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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.');
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
import { initTermsApp } from '~/terms';
|
||||
import { waitForCSSLoaded } from '~/helpers/startup_css_helper';
|
||||
|
||||
waitForCSSLoaded(initTermsApp);
|
|
@ -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>
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
};
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
|
@ -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
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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;' })
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
|
@ -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 |
|
@ -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.
|
|
@ -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).
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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' },
|
||||
|
|
|
@ -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 ""
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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!') }
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue