Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-10-27 15:08:39 +00:00
parent eb004dc626
commit 2b1e7f7dac
61 changed files with 1178 additions and 1130 deletions

View File

@ -79,3 +79,33 @@ export default function initCopyToClipboard() {
clipboardData.setData('text/x-gfm', json.gfm);
});
}
/**
* Programmatically triggers a click event on a
* "copy to clipboard" button, causing its
* contents to be copied. Handles some of the messiniess
* around managing the button's tooltip.
* @param {HTMLElement} btnElement
*/
export function clickCopyToClipboardButton(btnElement) {
const $btnElement = $(btnElement);
// Ensure the button has already been tooltip'd.
// If the use hasn't yet interacted (i.e. hovered or clicked)
// with the button, Bootstrap hasn't yet initialized
// the tooltip, and its `data-original-title` will be `undefined`.
// This value is used in the functions above.
$btnElement.tooltip();
btnElement.dispatchEvent(new MouseEvent('mouseover'));
btnElement.click();
// Manually trigger the necessary events to hide the
// button's tooltip and allow the button to perform its
// tooltip cleanup (updating the title from "Copied" back
// to its original title, "Copy branch name").
setTimeout(() => {
btnElement.dispatchEvent(new MouseEvent('mouseout'));
$btnElement.tooltip('hide');
}, 2000);
}

View File

@ -4,6 +4,8 @@ import Sidebar from '../../right_sidebar';
import Shortcuts from './shortcuts';
import { CopyAsGFM } from '../markdown/copy_as_gfm';
import { getSelectedFragment } from '~/lib/utils/common_utils';
import { isElementVisible } from '~/lib/utils/dom_utils';
import { clickCopyToClipboardButton } from '~/behaviors/copy_to_clipboard';
export default class ShortcutsIssuable extends Shortcuts {
constructor() {
@ -14,6 +16,7 @@ export default class ShortcutsIssuable extends Shortcuts {
Mousetrap.bind('l', () => ShortcutsIssuable.openSidebarDropdown('labels'));
Mousetrap.bind('r', ShortcutsIssuable.replyWithSelectedText);
Mousetrap.bind('e', ShortcutsIssuable.editIssue);
Mousetrap.bind('b', ShortcutsIssuable.copyBranchName);
}
static replyWithSelectedText() {
@ -98,4 +101,18 @@ export default class ShortcutsIssuable extends Shortcuts {
Sidebar.instance.openDropdown(name);
return false;
}
static copyBranchName() {
// There are two buttons - one that is shown when the sidebar
// is expanded, and one that is shown when it's collapsed.
const allCopyBtns = Array.from(document.querySelectorAll('.sidebar-source-branch button'));
// Select whichever button is currently visible so that
// the "Copied" tooltip is shown when a click is simulated.
const visibleBtn = allCopyBtns.find(isElementVisible);
if (visibleBtn) {
clickCopyToClipboardButton(visibleBtn);
}
}
}

View File

@ -1,128 +0,0 @@
import { escape } from 'lodash';
import axios from '../lib/utils/axios_utils';
import { s__ } from '../locale';
import { deprecatedCreateFlash as Flash } from '../flash';
import { parseBoolean } from '../lib/utils/common_utils';
import statusCodes from '../lib/utils/http_status';
import VariableList from './ci_variable_list';
function generateErrorBoxContent(errors) {
const errorList = [].concat(errors).map(
errorString => `
<li>
${escape(errorString)}
</li>
`,
);
return `
<p>
${s__('CiVariable|Validation failed')}
</p>
<ul>
${errorList.join('')}
</ul>
`;
}
// Used for the variable list on CI/CD projects/groups settings page
export default class AjaxVariableList {
constructor({
container,
saveButton,
errorBox,
formField = 'variables',
saveEndpoint,
maskableRegex,
}) {
this.container = container;
this.saveButton = saveButton;
this.errorBox = errorBox;
this.saveEndpoint = saveEndpoint;
this.maskableRegex = maskableRegex;
this.variableList = new VariableList({
container: this.container,
formField,
maskableRegex,
});
this.bindEvents();
this.variableList.init();
}
bindEvents() {
this.saveButton.addEventListener('click', this.onSaveClicked.bind(this));
}
onSaveClicked() {
const loadingIcon = this.saveButton.querySelector('.js-ci-variables-save-loading-icon');
loadingIcon.classList.toggle('hide', false);
this.errorBox.classList.toggle('hide', true);
// We use this to prevent a user from changing a key before we have a chance
// to match it up in `updateRowsWithPersistedVariables`
this.variableList.toggleEnableRow(false);
return axios
.patch(
this.saveEndpoint,
{
variables_attributes: this.variableList.getAllData(),
},
{
// We want to be able to process the `res.data` from a 400 error response
// and print the validation messages such as duplicate variable keys
validateStatus: status =>
(status >= statusCodes.OK && status < statusCodes.MULTIPLE_CHOICES) ||
status === statusCodes.BAD_REQUEST,
},
)
.then(res => {
loadingIcon.classList.toggle('hide', true);
this.variableList.toggleEnableRow(true);
if (res.status === statusCodes.OK && res.data) {
this.updateRowsWithPersistedVariables(res.data.variables);
this.variableList.hideValues();
} else if (res.status === statusCodes.BAD_REQUEST) {
// Validation failed
this.errorBox.innerHTML = generateErrorBoxContent(res.data);
this.errorBox.classList.toggle('hide', false);
}
})
.catch(() => {
loadingIcon.classList.toggle('hide', true);
this.variableList.toggleEnableRow(true);
Flash(s__('CiVariable|Error occurred while saving variables'));
});
}
updateRowsWithPersistedVariables(persistedVariables = []) {
const persistedVariableMap = [].concat(persistedVariables).reduce(
(variableMap, variable) => ({
...variableMap,
[variable.key]: variable,
}),
{},
);
this.container.querySelectorAll('.js-row').forEach(row => {
// If we submitted a row that was destroyed, remove it so we don't try
// to destroy it again which would cause a BE error
const destroyInput = row.querySelector('.js-ci-variable-input-destroy');
if (parseBoolean(destroyInput.value)) {
row.remove();
// Update the ID input so any future edits and `_destroy` will apply on the BE
} else {
const key = row.querySelector('.js-ci-variable-input-key').value;
const persistedVariable = persistedVariableMap[key];
if (persistedVariable) {
// eslint-disable-next-line no-param-reassign
row.querySelector('.js-ci-variable-input-id').value = persistedVariable.id;
row.setAttribute('data-is-persisted', 'true');
}
}
});
}
}

View File

@ -60,7 +60,7 @@ export default {
</script>
<template>
<gl-dropdown :text="value">
<gl-search-box-by-type v-model.trim="searchTerm" />
<gl-search-box-by-type v-model.trim="searchTerm" data-testid="ci-environment-search" />
<gl-dropdown-item
v-for="environment in filteredResults"
:key="environment"
@ -75,7 +75,7 @@ export default {
}}</gl-dropdown-item>
<template v-if="shouldRenderCreateButton">
<gl-dropdown-divider />
<gl-dropdown-item @click="createClicked">
<gl-dropdown-item data-testid="create-wildcard-button" @click="createClicked">
{{ composedCreateButtonLabel }}
</gl-dropdown-item>
</template>

View File

@ -236,6 +236,7 @@ export default {
:label="__('Environment scope')"
label-for="ci-variable-env"
class="w-50"
data-testid="environment-scope"
>
<ci-environments-dropdown
class="w-100"
@ -247,7 +248,11 @@ export default {
</div>
<gl-form-group :label="__('Flags')" label-for="ci-variable-flags">
<gl-form-checkbox v-model="protected_variable" class="mb-0">
<gl-form-checkbox
v-model="protected_variable"
class="mb-0"
data-testid="ci-variable-protected-checkbox"
>
{{ __('Protect variable') }}
<gl-link target="_blank" :href="protectedEnvironmentVariablesLink">
<gl-icon name="question" :size="12" />
@ -261,6 +266,7 @@ export default {
ref="masked-ci-variable"
v-model="masked"
data-qa-selector="ci_variable_masked_checkbox"
data-testid="ci-variable-masked-checkbox"
>
{{ __('Mask variable') }}
<gl-link target="_blank" :href="maskedEnvironmentVariablesLink">

View File

@ -1,10 +1,11 @@
<script>
/* eslint-disable vue/no-v-html */
import { GlLoadingIcon } from '@gitlab/ui';
import { GlLoadingIcon, GlButton } from '@gitlab/ui';
export default {
components: {
GlLoadingIcon,
GlButton,
},
props: {
isLoading: {
@ -41,24 +42,24 @@ export default {
};
</script>
<template>
<div class="text-center p-3">
<div class="gl-text-center gl-p-5">
<div v-if="illustrationPath" class="svg-content svg-130"><img :src="illustrationPath" /></div>
<h4>{{ __('Web Terminal') }}</h4>
<gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3" />
<template v-else>
<p>{{ __('Run tests against your code live using the Web Terminal') }}</p>
<p>
<button
<gl-button
:disabled="!isValid"
class="btn btn-info"
type="button"
category="primary"
variant="info"
data-qa-selector="start_web_terminal_button"
@click="onStart"
>
{{ __('Start Web Terminal') }}
</button>
</gl-button>
</p>
<div v-if="!isValid && message" class="bs-callout text-left" v-html="message"></div>
<div v-if="!isValid && message" class="bs-callout gl-text-left" v-html="message"></div>
<p v-else>
<a
v-if="helpPath"

View File

@ -47,3 +47,25 @@ export const parseBooleanDataAttributes = ({ dataset }, names) =>
return acc;
}, {});
/**
* Returns whether or not the provided element is currently visible.
* This function operates identically to jQuery's `:visible` pseudo-selector.
* Documentation for this selector: https://api.jquery.com/visible-selector/
* Implementation of this selector: https://github.com/jquery/jquery/blob/d0ce00cdfa680f1f0c38460bc51ea14079ae8b07/src/css/hiddenVisibleSelectors.js#L8
* @param {HTMLElement} element The element to test
* @returns {Boolean} `true` if the element is currently visible, otherwise false
*/
export const isElementVisible = element =>
Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
/**
* The opposite of `isElementVisible`.
* Returns whether or not the provided element is currently hidden.
* This function operates identically to jQuery's `:hidden` pseudo-selector.
* Documentation for this selector: https://api.jquery.com/hidden-selector/
* Implementation of this selector: https://github.com/jquery/jquery/blob/d0ce00cdfa680f1f0c38460bc51ea14079ae8b07/src/css/hiddenVisibleSelectors.js#L6
* @param {HTMLElement} element The element to test
* @returns {Boolean} `true` if the element is currently hidden, otherwise false
*/
export const isElementHidden = element => !isElementVisible(element);

View File

@ -40,6 +40,7 @@ import { initUserTracking, initDefaultTrackers } from './tracking';
import { __ } from './locale';
import * as tooltips from '~/tooltips';
import * as popovers from '~/popovers';
import 'ee_else_ce/main_ee';
@ -81,7 +82,7 @@ document.addEventListener('beforeunload', () => {
// Close any open tooltips
tooltips.dispose(document.querySelectorAll('.has-tooltip, [data-toggle="tooltip"]'));
// Close any open popover
$('[data-toggle="popover"]').popover('dispose');
popovers.dispose();
});
window.addEventListener('hashchange', handleLocationHash);
@ -166,13 +167,7 @@ function deferredInitialisation() {
});
// Initialize popovers
$body.popover({
selector: '[data-toggle="popover"]',
trigger: 'focus',
// set the viewport to the main content, excluding the navigation bar, so
// the navigation can't overlap the popover
viewport: '.layout-page',
});
popovers.initPopovers();
// Adding a helper class to activate animations only after all is rendered
setTimeout(() => $body.addClass('page-initialised'), 1000);

View File

@ -1,5 +1,4 @@
import initSettingsPanels from '~/settings_panels';
import AjaxVariableList from '~/ci_variable_list/ajax_variable_list';
import initVariableList from '~/ci_variable_list';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import GroupRunnersFilteredSearchTokenKeys from '~/filtered_search/group_runners_filtered_search_token_keys';
@ -17,19 +16,6 @@ document.addEventListener('DOMContentLoaded', () => {
useDefaultState: false,
});
if (gon.features.newVariablesUi) {
initVariableList();
} else {
const variableListEl = document.querySelector('.js-ci-variable-list-section');
// eslint-disable-next-line no-new
new AjaxVariableList({
container: variableListEl,
saveButton: variableListEl.querySelector('.js-ci-variables-save-button'),
errorBox: variableListEl.querySelector('.js-ci-variable-error-box'),
saveEndpoint: variableListEl.dataset.saveEndpoint,
maskableRegex: variableListEl.dataset.maskableRegex,
});
}
initSharedRunnersForm();
initVariableList();
});

View File

@ -1,6 +1,5 @@
import initSettingsPanels from '~/settings_panels';
import SecretValues from '~/behaviors/secret_values';
import AjaxVariableList from '~/ci_variable_list/ajax_variable_list';
import registrySettingsApp from '~/registry/settings/registry_settings_bundle';
import initVariableList from '~/ci_variable_list';
import initDeployFreeze from '~/deploy_freeze';
@ -18,19 +17,7 @@ document.addEventListener('DOMContentLoaded', () => {
runnerTokenSecretValue.init();
}
if (gon.features.newVariablesUi) {
initVariableList();
} else {
const variableListEl = document.querySelector('.js-ci-variable-list-section');
// eslint-disable-next-line no-new
new AjaxVariableList({
container: variableListEl,
saveButton: variableListEl.querySelector('.js-ci-variables-save-button'),
errorBox: variableListEl.querySelector('.js-ci-variable-error-box'),
saveEndpoint: variableListEl.dataset.saveEndpoint,
maskableRegex: variableListEl.dataset.maskableRegex,
});
}
initVariableList();
// hide extra auto devops settings based checkbox state
const autoDevOpsExtraSettings = document.querySelector('.js-extra-settings');

View File

@ -0,0 +1,92 @@
<script>
// We can't use v-safe-html here as the popover's title or content might contains SVGs that would
// be stripped by the directive's sanitizer. Instead, we fallback on v-html and we use GitLab's
// dompurify config that lets SVGs be rendered properly.
// Context: https://gitlab.com/gitlab-org/gitlab/-/issues/247207
/* eslint-disable vue/no-v-html */
import { GlPopover } from '@gitlab/ui';
import { sanitize } from '~/lib/dompurify';
const newPopover = element => {
const { content, html, placement, title, triggers = 'focus' } = element.dataset;
return {
target: element,
content,
html,
placement,
title,
triggers,
};
};
export default {
components: {
GlPopover,
},
data() {
return {
popovers: [],
};
},
created() {
this.observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
mutation.removedNodes.forEach(this.dispose);
});
});
},
beforeDestroy() {
this.observer.disconnect();
},
methods: {
addPopovers(elements) {
const newPopovers = elements.reduce((acc, element) => {
if (this.popoverExists(element)) {
return acc;
}
const popover = newPopover(element);
this.observe(popover);
return [...acc, popover];
}, []);
this.popovers.push(...newPopovers);
},
observe(popover) {
this.observer.observe(popover.target.parentElement, {
childList: true,
});
},
dispose(target) {
if (!target) {
this.popovers = [];
} else {
const index = this.popovers.findIndex(popover => popover.target === target);
if (index > -1) {
this.popovers.splice(index, 1);
}
}
},
popoverExists(element) {
return this.popovers.some(popover => popover.target === element);
},
getSafeHtml(html) {
return sanitize(html);
},
},
};
</script>
<template>
<div>
<gl-popover v-for="(popover, index) in popovers" :key="index" v-bind="popover">
<template #title>
<span v-if="popover.html" v-html="getSafeHtml(popover.title)"></span>
<span v-else>{{ popover.title }}</span>
</template>
<span v-if="popover.html" v-html="getSafeHtml(popover.content)"></span>
<span v-else>{{ popover.content }}</span>
</gl-popover>
</div>
</template>

View File

@ -0,0 +1,51 @@
import Vue from 'vue';
import { toArray } from 'lodash';
import PopoversComponent from './components/popovers.vue';
let app;
const APP_ELEMENT_ID = 'gl-popovers-app';
const getPopoversApp = () => {
if (!app) {
const container = document.createElement('div');
container.setAttribute('id', APP_ELEMENT_ID);
document.body.appendChild(container);
const Popovers = Vue.extend(PopoversComponent);
app = new Popovers();
app.$mount(`#${APP_ELEMENT_ID}`);
}
return app;
};
const isPopover = (node, selector) => node.matches && node.matches(selector);
const handlePopoverEvent = (rootTarget, e, selector) => {
for (let { target } = e; target && target !== rootTarget; target = target.parentNode) {
if (isPopover(target, selector)) {
getPopoversApp().addPopovers([target]);
break;
}
}
};
export const initPopovers = () => {
['mouseenter', 'focus', 'click'].forEach(event => {
document.addEventListener(
event,
e => handlePopoverEvent(document, e, '[data-toggle="popover"]'),
true,
);
});
return getPopoversApp();
};
export const dispose = elements => toArray(elements).forEach(getPopoversApp().dispose);
export const destroy = () => {
getPopoversApp().$destroy();
app = null;
};

View File

@ -1,6 +1,5 @@
<script>
/* eslint-disable vue/no-v-html */
import Mousetrap from 'mousetrap';
import { escape } from 'lodash';
import {
GlButton,
@ -84,17 +83,6 @@ export default {
: '';
},
},
mounted() {
Mousetrap.bind('b', this.copyBranchName);
},
beforeDestroy() {
Mousetrap.unbind('b');
},
methods: {
copyBranchName() {
this.$refs.copyBranchNameButton.$el.click();
},
},
};
</script>
<template>
@ -110,7 +98,6 @@ export default {
class="label-branch label-truncate js-source-branch"
v-html="mr.sourceBranchLink"
/><clipboard-button
ref="copyBranchNameButton"
data-testid="mr-widget-copy-clipboard"
:text="branchNameClipboardData"
:title="__('Copy branch name')"

View File

@ -9,9 +9,15 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25);
&.sticky {
position: sticky;
position: -webkit-sticky;
top: $flash-container-top;
z-index: 251;
.flash-alert,
.flash-notice,
.flash-success,
.flash-warning {
@include gl-mb-4;
}
}
&.flash-container-page {

View File

@ -1039,9 +1039,3 @@ $mr-widget-min-height: 69px;
.diff-file-row.is-active {
background-color: $gray-50;
}
.merge-request-container {
.flash-container {
@include gl-mb-4;
}
}

View File

@ -8,9 +8,6 @@ module Groups
skip_cross_project_access_check :show
before_action :authorize_admin_group!
before_action :authorize_update_max_artifacts_size!, only: [:update]
before_action do
push_frontend_feature_flag(:new_variables_ui, @group, default_enabled: true)
end
before_action :define_variables, only: [:show]
feature_category :continuous_integration

View File

@ -55,7 +55,7 @@ class Projects::ImportsController < Projects::ApplicationController
end
def require_namespace_project_creation_permission
render_404 unless current_user.can?(:admin_project, @project) || current_user.can?(:create_projects, @project.namespace)
render_404 unless can?(current_user, :admin_project, @project) || can?(current_user, :create_projects, @project.namespace)
end
def redirect_if_progress

View File

@ -20,7 +20,10 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
end
def diffs_batch
diffs = @compare.diffs_in_batch(params[:page], params[:per_page], diff_options: diff_options)
diff_options_hash = diff_options
diff_options_hash[:paths] = params[:paths] if params[:paths]
diffs = @compare.diffs_in_batch(params[:page], params[:per_page], diff_options: diff_options_hash)
positions = @merge_request.note_positions_for_paths(diffs.diff_file_paths, current_user)
environment = @merge_request.environments_for(current_user, latest: true).last

View File

@ -8,7 +8,6 @@ module Projects
before_action :authorize_admin_pipeline!
before_action :define_variables
before_action do
push_frontend_feature_flag(:new_variables_ui, @project, default_enabled: true)
push_frontend_feature_flag(:ajax_new_deploy_token, @project)
end

View File

@ -31,8 +31,18 @@ class EnvironmentNamesFinder
end
def namespace_environments
projects =
project_or_group.all_projects.public_or_visible_to_user(current_user)
# We assume reporter access is needed for the :read_environment permission
# here. This expection is also present in
# IssuableFinder::Params#min_access_level, which is used for filtering out
# merge requests that don't have the right permissions.
#
# We use this approach so we don't need to load every project into memory
# just to verify if we can see their environments. Doing so would not be
# efficient, and possibly mess up pagination if certain projects are not
# meant to be visible.
projects = project_or_group
.all_projects
.public_or_visible_to_user(current_user, Gitlab::Access::REPORTER)
Environment.for_project(projects)
end

View File

@ -57,10 +57,7 @@ module PageLayoutHelper
subject = @project || @user || @group
args = {}
args[:only_path] = false if Feature.enabled?(:avatar_with_host)
image = subject.avatar_url(args) if subject.present?
image = subject.avatar_url(only_path: false) if subject.present?
image || default
end

View File

@ -8,6 +8,10 @@ class MergeRequestDiffFile < ApplicationRecord
belongs_to :merge_request_diff, inverse_of: :merge_request_diff_files
alias_attribute :index, :relative_order
scope :by_paths, ->(paths) do
where("new_path in (?) OR old_path in (?)", paths, paths)
end
def utf8_diff
return '' if diff.blank?

View File

@ -11,8 +11,13 @@ class PaginatedDiffEntity < Grape::Entity
expose :diff_files do |diffs, options|
submodule_links = Gitlab::SubmoduleLinks.new(merge_request.project.repository)
DiffFileEntity.represent(diffs.diff_files,
options.merge(submodule_links: submodule_links, code_navigation_path: code_navigation_path(diffs)))
DiffFileEntity.represent(
diffs.diff_files,
options.merge(
submodule_links: submodule_links,
code_navigation_path: code_navigation_path(diffs)
)
)
end
expose :pagination do

View File

@ -5,42 +5,20 @@
- link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/README', anchor: 'protect-a-custom-variable') }
= s_('Environment variables are configured by your administrator to be %{link_start}protected%{link_end} by default').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
- if Feature.enabled?(:new_variables_ui, @project || @group, default_enabled: true)
- is_group = !@group.nil?
- is_group = !@group.nil?
#js-ci-project-variables{ data: { endpoint: save_endpoint,
project_id: @project&.id || '',
group: is_group.to_s,
maskable_regex: ci_variable_maskable_regex,
protected_by_default: ci_variable_protected_by_default?.to_s,
aws_logo_svg_path: image_path('aws_logo.svg'),
aws_tip_deploy_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'deploy-your-application-to-the-aws-elastic-container-service-ecs'),
aws_tip_commands_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'run-aws-commands-from-gitlab-cicd'),
aws_tip_learn_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'aws'),
protected_environment_variables_link: help_page_path('ci/variables/README', anchor: 'protect-a-custom-variable'),
masked_environment_variables_link: help_page_path('ci/variables/README', anchor: 'mask-a-custom-variable'),
} }
- else
.row
.col-lg-12.js-ci-variable-list-section{ data: { save_endpoint: save_endpoint, maskable_regex: ci_variable_maskable_regex } }
.hide.gl-alert.gl-alert-danger.js-ci-variable-error-box
%ul.ci-variable-list
= render 'ci/variables/variable_header'
- @variables.each.each do |variable|
= render 'ci/variables/variable_row', form_field: 'variables', variable: variable
= render 'ci/variables/variable_row', form_field: 'variables'
.prepend-top-20
%button.btn.btn-success.js-ci-variables-save-button{ type: 'button' }
%span.hide.js-ci-variables-save-loading-icon
.spinner.spinner-light.mr-1
= _('Save variables')
%button.btn.btn-info.btn-inverted.gl-ml-3.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: "#{@variables.size == 0}" } }
- if @variables.size == 0
= n_('Hide value', 'Hide values', @variables.size)
- else
= n_('Reveal value', 'Reveal values', @variables.size)
#js-ci-project-variables{ data: { endpoint: save_endpoint,
project_id: @project&.id || '',
group: is_group.to_s,
maskable_regex: ci_variable_maskable_regex,
protected_by_default: ci_variable_protected_by_default?.to_s,
aws_logo_svg_path: image_path('aws_logo.svg'),
aws_tip_deploy_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'deploy-your-application-to-the-aws-elastic-container-service-ecs'),
aws_tip_commands_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'run-aws-commands-from-gitlab-cicd'),
aws_tip_learn_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'aws'),
protected_environment_variables_link: help_page_path('ci/variables/README', anchor: 'protect-a-custom-variable'),
masked_environment_variables_link: help_page_path('ci/variables/README', anchor: 'mask-a-custom-variable'),
} }
- if !@group && @project.group
.settings-header.border-top.prepend-top-20

View File

@ -42,7 +42,7 @@
toggle: "popover",
placement: "top",
html: "true",
trigger: "focus",
triggers: "focus",
title: "<div class='gl-font-weight-normal gl-line-height-normal'>#{popover_title_text}</div>",
content: "<a href='#{popover_content_url}' target='_blank' rel='noopener noreferrer nofollow'>#{popover_content_text}</a>",
} }

View File

@ -0,0 +1,5 @@
---
title: Enable rendering avatars with full url
merge_request: 46206
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Fix bug with robots and .git suffix
merge_request: 45866
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Fix bug accessing import route with no user
merge_request: 46215
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Remove new_variables_ui feature flag
merge_request: 41412
author:
type: other

View File

@ -0,0 +1,5 @@
---
title: Update copy branch keyboard shortcut to click sidebar button
merge_request: 45436
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Reduce whitespace on MR page header
merge_request: 45966
author:
type: fixed

View File

@ -1,7 +0,0 @@
---
name: avatar_with_host
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45776
rollout_issue_url:
type: development
group: group::editor
default_enabled: false

View File

@ -1,7 +0,0 @@
---
name: new_variables_ui
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/25260
rollout_issue_url:
group: group::continuous integration
type: development
default_enabled: true

View File

@ -212,39 +212,7 @@ When the user is added back to the SCIM app, GitLab cannot create a new user bec
Solution: Have a user sign in directly to GitLab, then [manually link](#user-access-and-linking-setup) their account.
### Azure
#### How do I verify my SCIM configuration is correct?
Review the following:
- Ensure that the SCIM value for `id` matches the SAML value for `NameId`.
- Ensure that the SCIM value for `externalId` matches the SAML value for `NameId`.
Review the following SCIM parameters for sensible values:
- `userName`
- `displayName`
- `emails[type eq "work"].value`
#### Testing Azure connection: invalid credentials
When testing the connection, you may encounter an error: **You appear to have entered invalid credentials. Please confirm you are using the correct information for an administrative account**. If `Tenant URL` and `secret token` are correct, check whether your group path contains characters that may be considered invalid JSON primitives (such as `.`). Removing such characters from the group path typically resolves the error.
#### Azure: (Field) can't be blank sync error
When checking the Audit Logs for the Provisioning, you can sometimes see the
error `Namespace can't be blank, Name can't be blank, and User can't be blank.`
This is likely caused because not all required fields (such as first name and last name) are present for all users being mapped.
As a workaround, try an alternate mapping:
1. Follow the Azure mapping instructions from above.
1. Delete the `name.formatted` target attribute entry.
1. Change the `displayName` source attribute to have `name.formatted` target attribute.
#### How do I diagnose why a user is unable to sign in
### How do I diagnose why a user is unable to sign in
Ensure that the user has been added to the SCIM app.
@ -256,7 +224,7 @@ This value is also used by SCIM to match users on the `id`, and is updated by SC
It is important that this SCIM `id` and SCIM `externalId` are configured to the same value as the SAML `NameId`. SAML responses can be traced using [debugging tools](./index.md#saml-debugging-tools), and any errors can be checked against our [SAML troubleshooting docs](./index.md#troubleshooting).
#### How do I verify user's SAML NameId matches the SCIM externalId
### How do I verify user's SAML NameId matches the SCIM externalId
Group owners can see the list of users and the `externalId` stored for each user in the group SAML SSO Settings page.
@ -264,7 +232,7 @@ A possible alternative is to use the [SCIM API](../../../api/scim.md#get-a-list-
To see how the `external_uid` compares to the value returned as the SAML NameId, you can have the user use a [SAML Tracer](index.md#saml-debugging-tools).
#### Update or fix mismatched SCIM externalId and SAML NameId
### Update or fix mismatched SCIM externalId and SAML NameId
Whether the value was changed or you need to map to a different field, ensure `id`, `externalId`, and `NameId` all map to the same field.
@ -284,8 +252,40 @@ you can address the problem in the following ways:
It is important not to update these to incorrect values, since this will cause users to be unable to sign in. It is also important not to assign a value to the wrong user, as this would cause users to get signed into the wrong account.
#### I need to change my SCIM app
### I need to change my SCIM app
Individual users can follow the instructions in the ["SAML authentication failed: User has already been taken"](./index.md#i-need-to-change-my-saml-app) section.
Alternatively, users can be removed from the SCIM app which will delink all removed users. Sync can then be turned on for the new SCIM app to [link existing users](#user-access-and-linking-setup).
### Azure
#### How do I verify my SCIM configuration is correct?
Review the following:
- Ensure that the SCIM value for `id` matches the SAML value for `NameId`.
- Ensure that the SCIM value for `externalId` matches the SAML value for `NameId`.
Review the following SCIM parameters for sensible values:
- `userName`
- `displayName`
- `emails[type eq "work"].value`
#### Testing Azure connection: invalid credentials
When testing the connection, you may encounter an error: **You appear to have entered invalid credentials. Please confirm you are using the correct information for an administrative account**. If `Tenant URL` and `secret token` are correct, check whether your group path contains characters that may be considered invalid JSON primitives (such as `.`). Removing such characters from the group path typically resolves the error.
#### (Field) can't be blank sync error
When checking the Audit Logs for the Provisioning, you can sometimes see the
error `Namespace can't be blank, Name can't be blank, and User can't be blank.`
This is likely caused because not all required fields (such as first name and last name) are present for all users being mapped.
As a workaround, try an alternate mapping:
1. Follow the Azure mapping instructions from above.
1. Delete the `name.formatted` target attribute entry.
1. Change the `displayName` source attribute to have `name.formatted` target attribute.

View File

@ -18,10 +18,8 @@ module Gitlab
def initialize(merge_request_diff, batch_page, batch_size, diff_options:)
super(merge_request_diff, diff_options: diff_options)
batch_page ||= DEFAULT_BATCH_PAGE
batch_size ||= DEFAULT_BATCH_SIZE
@paginated_collection = load_paginated_collection(batch_page, batch_size, diff_options)
@paginated_collection = relation.page(batch_page).per(batch_size)
@pagination_data = {
current_page: @paginated_collection.current_page,
next_page: @paginated_collection.next_page,
@ -63,6 +61,18 @@ module Gitlab
def relation
@merge_request_diff.merge_request_diff_files
end
def load_paginated_collection(batch_page, batch_size, diff_options)
batch_page ||= DEFAULT_BATCH_PAGE
batch_size ||= DEFAULT_BATCH_SIZE
paths = diff_options&.fetch(:paths, nil)
paginated_collection = relation.page(batch_page).per(batch_size)
paginated_collection = paginated_collection.by_paths(paths) if paths
paginated_collection
end
end
end
end

View File

@ -5321,9 +5321,6 @@ msgstr ""
msgid "CiVariable|Create wildcard"
msgstr ""
msgid "CiVariable|Error occurred while saving variables"
msgstr ""
msgid "CiVariable|Masked"
msgstr ""
@ -5342,9 +5339,6 @@ msgstr ""
msgid "CiVariable|Toggle protected"
msgstr ""
msgid "CiVariable|Validation failed"
msgstr ""
msgid "Classification Label (optional)"
msgstr ""
@ -23050,9 +23044,6 @@ msgstr ""
msgid "Save space and find tags in the Container Registry more easily. Enable the cleanup policy to remove stale tags and keep only the ones you need."
msgstr ""
msgid "Save variables"
msgstr ""
msgid "Saved scan settings and target site settings which are reusable."
msgstr ""

View File

@ -42,7 +42,7 @@ Disallow: /groups/*/group_members
# Project details
User-Agent: *
Disallow: /*/*.git
Disallow: /*/*.git$
Disallow: /*/archive/
Disallow: /*/repository/archive*
Disallow: /*/activity

View File

@ -7,10 +7,21 @@ RSpec.describe Projects::ImportsController do
let(:project) { create(:project) }
before do
sign_in(user)
sign_in(user) if user
end
describe 'GET #show' do
context 'when user is not authenticated and the project is public' do
let(:user) { nil }
let(:project) { create(:project, :public) }
it 'returns 404 response' do
get :show, params: { namespace_id: project.namespace.to_param, project_id: project }
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when the user has maintainer rights' do
before do
project.add_maintainer(user)

View File

@ -454,6 +454,31 @@ RSpec.describe Projects::MergeRequests::DiffsController do
it_behaves_like 'successful request'
end
context 'with paths param' do
let(:example_file_path) { "README" }
let(:file_path_option) { { paths: [example_file_path] } }
subject do
go(file_path_option)
end
it_behaves_like 'serializes diffs with expected arguments' do
let(:collection) { Gitlab::Diff::FileCollection::MergeRequestDiffBatch }
let(:expected_options) do
collection_arguments(current_page: 1, total_pages: 1)
end
end
it_behaves_like 'successful request'
it 'filters down the response to the expected file path' do
subject
expect(json_response["diff_files"].size).to eq(1)
expect(json_response["diff_files"].first["file_path"]).to eq(example_file_path)
end
end
context 'with default params' do
subject { go }

View File

@ -11,7 +11,7 @@ RSpec.describe 'Group variables', :js do
before do
group.add_owner(user)
gitlab_sign_in(user)
stub_feature_flags(new_variables_ui: false)
wait_for_requests
visit page_path
end

View File

@ -24,7 +24,6 @@ RSpec.describe 'Project group variables', :js do
sign_in(user)
project.add_maintainer(user)
group.add_owner(user)
stub_feature_flags(new_variables_ui: false)
end
it 'project in group shows inherited vars from ancestor group' do
@ -53,9 +52,13 @@ RSpec.describe 'Project group variables', :js do
it 'project origin keys link to ancestor groups ci_cd settings' do
visit project_path
find('.group-origin-link').click
page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do
expect(find('.js-ci-variable-input-key').value).to eq(key1)
wait_for_requests
page.within('.ci-variable-table') do
expect(find('.js-ci-variable-row:nth-child(1) [data-label="Key"]').text).to eq(key1)
end
end
end

View File

@ -12,32 +12,29 @@ RSpec.describe 'Project variables', :js do
sign_in(user)
project.add_maintainer(user)
project.variables << variable
stub_feature_flags(new_variables_ui: false)
visit page_path
end
it_behaves_like 'variable list'
it 'adds new variable with a special environment scope' do
page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('somekey')
find('.js-ci-variable-input-value').set('somevalue')
it 'adds a new variable with an environment scope' do
click_button('Add Variable')
find('.js-variable-environment-toggle').click
find('.js-variable-environment-dropdown-wrapper .dropdown-input-field').set('review/*')
find('.js-variable-environment-dropdown-wrapper .js-dropdown-create-new-item').click
page.within('#add-ci-variable') do
find('[data-qa-selector="ci_variable_key_field"] input').set('akey')
find('#ci-variable-value').set('akey_value')
find('[data-testid="environment-scope"]').click
find_button('clear').click
find('[data-testid="ci-environment-search"]').set('review/*')
find('[data-testid="create-wildcard-button"]').click
expect(find('input[name="variables[variables_attributes][][environment_scope]"]', visible: false).value).to eq('review/*')
click_button('Add variable')
end
click_button('Save variables')
wait_for_requests
visit page_path
page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do
expect(find('.js-ci-variable-input-key').value).to eq('somekey')
expect(page).to have_content('review/*')
page.within('.ci-variable-table') do
expect(find('.js-ci-variable-row:first-child [data-label="Environments"]').text).to eq('review/*')
end
end
end

View File

@ -18,7 +18,6 @@ RSpec.describe 'Project > Settings > CI/CD > Container registry tag expiration p
sign_in(user)
stub_container_registry_config(enabled: container_registry_enabled)
stub_feature_flags(new_variables_ui: false)
end
context 'as owner' do

View File

@ -5,74 +5,178 @@ require 'spec_helper'
RSpec.describe EnvironmentNamesFinder do
describe '#execute' do
let!(:group) { create(:group) }
let!(:project1) { create(:project, :public, namespace: group) }
let!(:project2) { create(:project, :private, namespace: group) }
let!(:public_project) { create(:project, :public, namespace: group) }
let!(:private_project) { create(:project, :private, namespace: group) }
let!(:user) { create(:user) }
before do
create(:environment, name: 'gstg', project: project1)
create(:environment, name: 'gprd', project: project1)
create(:environment, name: 'gprd', project: project2)
create(:environment, name: 'gcny', project: project2)
create(:environment, name: 'gstg', project: public_project)
create(:environment, name: 'gprd', project: public_project)
create(:environment, name: 'gprd', project: private_project)
create(:environment, name: 'gcny', project: private_project)
end
context 'using a group and a group member' do
it 'returns environment names for all projects' do
group.add_developer(user)
context 'using a group' do
context 'with a group developer' do
it 'returns environment names for all projects' do
group.add_developer(user)
names = described_class.new(group, user).execute
names = described_class.new(group, user).execute
expect(names).to eq(%w[gcny gprd gstg])
expect(names).to eq(%w[gcny gprd gstg])
end
end
context 'with a group reporter' do
it 'returns environment names for all projects' do
group.add_reporter(user)
names = described_class.new(group, user).execute
expect(names).to eq(%w[gcny gprd gstg])
end
end
context 'with a public project reporter' do
it 'returns environment names for all public projects' do
public_project.add_reporter(user)
names = described_class.new(group, user).execute
expect(names).to eq(%w[gprd gstg])
end
end
context 'with a private project reporter' do
it 'returns environment names for all public projects' do
private_project.add_reporter(user)
names = described_class.new(group, user).execute
expect(names).to eq(%w[gcny gprd gstg])
end
end
context 'with a group guest' do
it 'returns environment names for all public projects' do
group.add_guest(user)
names = described_class.new(group, user).execute
expect(names).to eq(%w[gprd gstg])
end
end
context 'with a non-member' do
it 'returns environment names for all public projects' do
names = described_class.new(group, user).execute
expect(names).to eq(%w[gprd gstg])
end
end
context 'without a user' do
it 'returns environment names for all public projects' do
names = described_class.new(group).execute
expect(names).to eq(%w[gprd gstg])
end
end
end
context 'using a group and a guest' do
it 'returns environment names for all public projects' do
names = described_class.new(group, user).execute
context 'using a public project' do
context 'with a project developer' do
it 'returns all the unique environment names' do
public_project.add_developer(user)
expect(names).to eq(%w[gprd gstg])
names = described_class.new(public_project, user).execute
expect(names).to eq(%w[gprd gstg])
end
end
context 'with a project reporter' do
it 'returns all the unique environment names' do
public_project.add_reporter(user)
names = described_class.new(public_project, user).execute
expect(names).to eq(%w[gprd gstg])
end
end
context 'with a project guest' do
it 'returns all the unique environment names' do
public_project.add_guest(user)
names = described_class.new(public_project, user).execute
expect(names).to eq(%w[gprd gstg])
end
end
context 'with a non-member' do
it 'returns all the unique environment names' do
names = described_class.new(public_project, user).execute
expect(names).to eq(%w[gprd gstg])
end
end
context 'without a user' do
it 'returns all the unique environment names' do
names = described_class.new(public_project).execute
expect(names).to eq(%w[gprd gstg])
end
end
end
context 'using a public project and a project member' do
it 'returns all the unique environment names' do
project1.team.add_developer(user)
context 'using a private project' do
context 'with a project developer' do
it 'returns all the unique environment names' do
private_project.add_developer(user)
names = described_class.new(project1, user).execute
names = described_class.new(private_project, user).execute
expect(names).to eq(%w[gprd gstg])
expect(names).to eq(%w[gcny gprd])
end
end
end
context 'using a public project and a guest' do
it 'returns all the unique environment names' do
names = described_class.new(project1, user).execute
context 'with a project reporter' do
it 'returns all the unique environment names' do
private_project.add_reporter(user)
expect(names).to eq(%w[gprd gstg])
names = described_class.new(private_project, user).execute
expect(names).to eq(%w[gcny gprd])
end
end
end
context 'using a private project and a guest' do
it 'returns all the unique environment names' do
names = described_class.new(project2, user).execute
context 'with a project guest' do
it 'does not return any environment names' do
private_project.add_guest(user)
expect(names).to be_empty
names = described_class.new(private_project, user).execute
expect(names).to be_empty
end
end
end
context 'using a public project without a user' do
it 'returns all the unique environment names' do
names = described_class.new(project1).execute
context 'with a non-member' do
it 'does not return any environment names' do
names = described_class.new(private_project, user).execute
expect(names).to eq(%w[gprd gstg])
expect(names).to be_empty
end
end
end
context 'using a private project without a user' do
it 'does not return any environment names' do
names = described_class.new(project2).execute
context 'without a user' do
it 'does not return any environment names' do
names = described_class.new(private_project).execute
expect(names).to eq([])
expect(names).to be_empty
end
end
end
end

View File

@ -38,8 +38,16 @@ jest.mock('@gitlab/ui/dist/components/base/popover/popover.js', () => ({
required: false,
default: () => [],
},
...Object.fromEntries(['target', 'triggers', 'placement'].map(prop => [prop, {}])),
},
render(h) {
return h('div', this.$attrs, Object.keys(this.$slots).map(s => this.$slots[s]));
return h(
'div',
{
class: 'gl-popover',
...this.$attrs,
},
Object.keys(this.$slots).map(s => this.$slots[s]),
);
},
}));

View File

@ -1,20 +1,19 @@
import $ from 'jquery';
import 'mousetrap';
import Mousetrap from 'mousetrap';
import initCopyAsGFM, { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import { getSelectedFragment } from '~/lib/utils/common_utils';
const FORM_SELECTOR = '.js-main-target-form .js-vue-comment-form';
jest.mock('~/lib/utils/common_utils', () => ({
...jest.requireActual('~/lib/utils/common_utils'),
getSelectedFragment: jest.fn().mockName('getSelectedFragment'),
}));
describe('ShortcutsIssuable', () => {
const fixtureName = 'snippets/show.html';
const snippetShowFixtureName = 'snippets/show.html';
const mrShowFixtureName = 'merge_requests/merge_request_of_current_user.html';
preloadFixtures(fixtureName);
preloadFixtures(snippetShowFixtureName, mrShowFixtureName);
beforeAll(done => {
initCopyAsGFM();
@ -27,25 +26,27 @@ describe('ShortcutsIssuable', () => {
.catch(done.fail);
});
beforeEach(() => {
loadFixtures(fixtureName);
$('body').append(
`<div class="js-main-target-form">
<textarea class="js-vue-comment-form"></textarea>
</div>`,
);
document.querySelector('.js-new-note-form').classList.add('js-main-target-form');
window.shortcut = new ShortcutsIssuable(true);
});
afterEach(() => {
$(FORM_SELECTOR).remove();
delete window.shortcut;
});
describe('replyWithSelectedText', () => {
const FORM_SELECTOR = '.js-main-target-form .js-vue-comment-form';
beforeEach(() => {
loadFixtures(snippetShowFixtureName);
$('body').append(
`<div class="js-main-target-form">
<textarea class="js-vue-comment-form"></textarea>
</div>`,
);
document.querySelector('.js-new-note-form').classList.add('js-main-target-form');
window.shortcut = new ShortcutsIssuable(true);
});
afterEach(() => {
$(FORM_SELECTOR).remove();
delete window.shortcut;
});
// Stub getSelectedFragment to return a node with the provided HTML.
const stubSelection = (html, invalidNode) => {
getSelectedFragment.mockImplementation(() => {
@ -319,4 +320,55 @@ describe('ShortcutsIssuable', () => {
});
});
});
describe('copyBranchName', () => {
let sidebarCollapsedBtn;
let sidebarExpandedBtn;
beforeEach(() => {
loadFixtures(mrShowFixtureName);
window.shortcut = new ShortcutsIssuable();
[sidebarCollapsedBtn, sidebarExpandedBtn] = document.querySelectorAll(
'.sidebar-source-branch button',
);
[sidebarCollapsedBtn, sidebarExpandedBtn].forEach(btn => jest.spyOn(btn, 'click'));
});
afterEach(() => {
delete window.shortcut;
});
describe('when the sidebar is expanded', () => {
beforeEach(() => {
// simulate the applied CSS styles when the
// sidebar is expanded
sidebarCollapsedBtn.style.display = 'none';
Mousetrap.trigger('b');
});
it('clicks the "expanded" version of the copy source branch button', () => {
expect(sidebarExpandedBtn.click).toHaveBeenCalled();
expect(sidebarCollapsedBtn.click).not.toHaveBeenCalled();
});
});
describe('when the sidebar is collapsed', () => {
beforeEach(() => {
// simulate the applied CSS styles when the
// sidebar is collapsed
sidebarExpandedBtn.style.display = 'none';
Mousetrap.trigger('b');
});
it('clicks the "collapsed" version of the copy source branch button', () => {
expect(sidebarCollapsedBtn.click).toHaveBeenCalled();
expect(sidebarExpandedBtn.click).not.toHaveBeenCalled();
});
});
});
});

View File

@ -1,203 +0,0 @@
import $ from 'jquery';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import AjaxFormVariableList from '~/ci_variable_list/ajax_variable_list';
const VARIABLE_PATCH_ENDPOINT = 'http://test.host/frontend-fixtures/builds-project/-/variables';
const HIDE_CLASS = 'hide';
describe('AjaxFormVariableList', () => {
preloadFixtures('projects/ci_cd_settings.html');
preloadFixtures('projects/ci_cd_settings_with_variables.html');
let container;
let saveButton;
let errorBox;
let mock;
let ajaxVariableList;
beforeEach(() => {
loadFixtures('projects/ci_cd_settings.html');
container = document.querySelector('.js-ci-variable-list-section');
mock = new MockAdapter(axios);
const ajaxVariableListEl = document.querySelector('.js-ci-variable-list-section');
saveButton = ajaxVariableListEl.querySelector('.js-ci-variables-save-button');
errorBox = container.querySelector('.js-ci-variable-error-box');
ajaxVariableList = new AjaxFormVariableList({
container,
formField: 'variables',
saveButton,
errorBox,
saveEndpoint: container.dataset.saveEndpoint,
maskableRegex: container.dataset.maskableRegex,
});
jest.spyOn(ajaxVariableList, 'updateRowsWithPersistedVariables');
jest.spyOn(ajaxVariableList.variableList, 'toggleEnableRow');
});
afterEach(() => {
mock.restore();
});
describe('onSaveClicked', () => {
it('shows loading spinner while waiting for the request', () => {
const loadingIcon = saveButton.querySelector('.js-ci-variables-save-loading-icon');
mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(() => {
expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(false);
return [200, {}];
});
expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(true);
return ajaxVariableList.onSaveClicked().then(() => {
expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(true);
});
});
it('calls `updateRowsWithPersistedVariables` with the persisted variables', () => {
const variablesResponse = [{ id: 1, key: 'foo', value: 'bar' }];
mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200, {
variables: variablesResponse,
});
return ajaxVariableList.onSaveClicked().then(() => {
expect(ajaxVariableList.updateRowsWithPersistedVariables).toHaveBeenCalledWith(
variablesResponse,
);
});
});
it('hides any previous error box', () => {
mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200);
expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
return ajaxVariableList.onSaveClicked().then(() => {
expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
});
});
it('disables remove buttons while waiting for the request', () => {
mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(() => {
expect(ajaxVariableList.variableList.toggleEnableRow).toHaveBeenCalledWith(false);
return [200, {}];
});
return ajaxVariableList.onSaveClicked().then(() => {
expect(ajaxVariableList.variableList.toggleEnableRow).toHaveBeenCalledWith(true);
});
});
it('hides secret values', () => {
mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200, {});
const row = container.querySelector('.js-row');
const valueInput = row.querySelector('.js-ci-variable-input-value');
const valuePlaceholder = row.querySelector('.js-secret-value-placeholder');
valueInput.value = 'bar';
$(valueInput).trigger('input');
expect(valuePlaceholder.classList.contains(HIDE_CLASS)).toBe(true);
expect(valueInput.classList.contains(HIDE_CLASS)).toBe(false);
return ajaxVariableList.onSaveClicked().then(() => {
expect(valuePlaceholder.classList.contains(HIDE_CLASS)).toBe(false);
expect(valueInput.classList.contains(HIDE_CLASS)).toBe(true);
});
});
it('shows error box with validation errors', () => {
const validationError = 'some validation error';
mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(400, [validationError]);
expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
return ajaxVariableList.onSaveClicked().then(() => {
expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(false);
expect(errorBox.textContent.trim().replace(/\n+\s+/m, ' ')).toEqual(
`Validation failed ${validationError}`,
);
});
});
it('shows flash message when request fails', () => {
mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(500);
expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
return ajaxVariableList.onSaveClicked().then(() => {
expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
});
});
});
describe('updateRowsWithPersistedVariables', () => {
beforeEach(() => {
loadFixtures('projects/ci_cd_settings_with_variables.html');
container = document.querySelector('.js-ci-variable-list-section');
const ajaxVariableListEl = document.querySelector('.js-ci-variable-list-section');
saveButton = ajaxVariableListEl.querySelector('.js-ci-variables-save-button');
errorBox = container.querySelector('.js-ci-variable-error-box');
ajaxVariableList = new AjaxFormVariableList({
container,
formField: 'variables',
saveButton,
errorBox,
saveEndpoint: container.dataset.saveEndpoint,
});
});
it('removes variable that was removed', () => {
expect(container.querySelectorAll('.js-row').length).toBe(3);
container.querySelector('.js-row-remove-button').click();
expect(container.querySelectorAll('.js-row').length).toBe(3);
ajaxVariableList.updateRowsWithPersistedVariables([]);
expect(container.querySelectorAll('.js-row').length).toBe(2);
});
it('updates new variable row with persisted ID', () => {
const row = container.querySelector('.js-row:last-child');
const idInput = row.querySelector('.js-ci-variable-input-id');
const keyInput = row.querySelector('.js-ci-variable-input-key');
const valueInput = row.querySelector('.js-ci-variable-input-value');
keyInput.value = 'foo';
$(keyInput).trigger('input');
valueInput.value = 'bar';
$(valueInput).trigger('input');
expect(idInput.value).toEqual('');
ajaxVariableList.updateRowsWithPersistedVariables([
{
id: 3,
key: 'foo',
value: 'bar',
},
]);
expect(idInput.value).toEqual('3');
expect(row.dataset.isPersisted).toEqual('true');
});
});
describe('maskableRegex', () => {
it('takes in the regex provided by the data attribute', () => {
expect(container.dataset.maskableRegex).toBe('^[a-zA-Z0-9_+=/@:.-]{8,}$');
expect(ajaxVariableList.maskableRegex).toBe(container.dataset.maskableRegex);
});
});
});

View File

@ -1,5 +1,4 @@
import $ from 'jquery';
import waitForPromises from 'helpers/wait_for_promises';
import VariableList from '~/ci_variable_list/ci_variable_list';
const HIDE_CLASS = 'hide';
@ -7,7 +6,6 @@ const HIDE_CLASS = 'hide';
describe('VariableList', () => {
preloadFixtures('pipeline_schedules/edit.html');
preloadFixtures('pipeline_schedules/edit_with_variables.html');
preloadFixtures('projects/ci_cd_settings.html');
let $wrapper;
let variableList;
@ -113,92 +111,6 @@ describe('VariableList', () => {
});
});
describe('with all inputs(key, value, protected)', () => {
beforeEach(() => {
loadFixtures('projects/ci_cd_settings.html');
$wrapper = $('.js-ci-variable-list-section');
$wrapper.find('.js-ci-variable-input-protected').attr('data-default', 'false');
variableList = new VariableList({
container: $wrapper,
formField: 'variables',
});
variableList.init();
});
it('should not add another row when editing the last rows protected checkbox', () => {
const $row = $wrapper.find('.js-row:last-child');
$row.find('.ci-variable-protected-item .js-project-feature-toggle').click();
return waitForPromises().then(() => {
expect($wrapper.find('.js-row').length).toBe(1);
});
});
it('should not add another row when editing the last rows masked checkbox', () => {
jest.spyOn(variableList, 'checkIfRowTouched');
const $row = $wrapper.find('.js-row:last-child');
$row.find('.ci-variable-masked-item .js-project-feature-toggle').click();
return waitForPromises().then(() => {
// This validates that we are checking after the event listener has run
expect(variableList.checkIfRowTouched).toHaveBeenCalled();
expect($wrapper.find('.js-row').length).toBe(1);
});
});
describe('validateMaskability', () => {
let $row;
const maskingErrorElement = '.js-row:last-child .masking-validation-error';
const clickToggle = () =>
$row.find('.ci-variable-masked-item .js-project-feature-toggle').click();
beforeEach(() => {
$row = $wrapper.find('.js-row:last-child');
});
it('has a regex provided via a data attribute', () => {
clickToggle();
expect($wrapper.attr('data-maskable-regex')).toBe('^[a-zA-Z0-9_+=/@:.-]{8,}$');
});
it('allows values that are 8 characters long', () => {
$row.find('.js-ci-variable-input-value').val('looooong');
clickToggle();
expect($wrapper.find(maskingErrorElement)).toHaveClass('hide');
});
it('rejects values that are shorter than 8 characters', () => {
$row.find('.js-ci-variable-input-value').val('short');
clickToggle();
expect($wrapper.find(maskingErrorElement)).toBeVisible();
});
it('allows values with base 64 characters', () => {
$row.find('.js-ci-variable-input-value').val('abcABC123_+=/-');
clickToggle();
expect($wrapper.find(maskingErrorElement)).toHaveClass('hide');
});
it('rejects values with other special characters', () => {
$row.find('.js-ci-variable-input-value').val('1234567$');
clickToggle();
expect($wrapper.find(maskingErrorElement)).toBeVisible();
});
});
});
describe('toggleEnableRow method', () => {
beforeEach(() => {
loadFixtures('pipeline_schedules/edit_with_variables.html');
@ -247,36 +159,4 @@ describe('VariableList', () => {
expect($wrapper.find('.js-ci-variable-input-key:not([disabled])').length).toBe(3);
});
});
describe('hideValues', () => {
beforeEach(() => {
loadFixtures('projects/ci_cd_settings.html');
$wrapper = $('.js-ci-variable-list-section');
variableList = new VariableList({
container: $wrapper,
formField: 'variables',
});
variableList.init();
});
it('should hide value input and show placeholder stars', () => {
const $row = $wrapper.find('.js-row');
const $inputValue = $row.find('.js-ci-variable-input-value');
const $placeholder = $row.find('.js-secret-value-placeholder');
$row
.find('.js-ci-variable-input-value')
.val('foo')
.trigger('input');
expect($placeholder.hasClass(HIDE_CLASS)).toBe(true);
expect($inputValue.hasClass(HIDE_CLASS)).toBe(false);
variableList.hideValues();
expect($placeholder.hasClass(HIDE_CLASS)).toBe(false);
expect($inputValue.hasClass(HIDE_CLASS)).toBe(true);
});
});
});

View File

@ -15,7 +15,6 @@ RSpec.describe 'Groups (JavaScript fixtures)', type: :controller do
end
before do
stub_feature_flags(new_variables_ui: false)
group.add_maintainer(admin)
sign_in(admin)
end
@ -27,12 +26,4 @@ RSpec.describe 'Groups (JavaScript fixtures)', type: :controller do
expect(response).to be_successful
end
end
describe Groups::Settings::CiCdController, '(JavaScript fixtures)', type: :controller do
it 'groups/ci_cd_settings.html' do
get :show, params: { group_id: group }
expect(response).to be_successful
end
end
end

View File

@ -20,7 +20,6 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do
end
before do
stub_feature_flags(new_variables_ui: false)
project.add_maintainer(admin)
sign_in(admin)
allow(SecureRandom).to receive(:hex).and_return('securerandomhex:thereisnospoon')
@ -58,27 +57,4 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do
expect(response).to be_successful
end
end
describe Projects::Settings::CiCdController, '(JavaScript fixtures)', type: :controller do
it 'projects/ci_cd_settings.html' do
get :show, params: {
namespace_id: project.namespace.to_param,
project_id: project
}
expect(response).to be_successful
end
it 'projects/ci_cd_settings_with_variables.html' do
create(:ci_variable, project: project_variable_populated)
create(:ci_variable, project: project_variable_populated)
get :show, params: {
namespace_id: project_variable_populated.namespace.to_param,
project_id: project_variable_populated
}
expect(response).to be_successful
end
end
end

View File

@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import { GlLoadingIcon, GlButton } from '@gitlab/ui';
import { TEST_HOST } from 'spec/test_constants';
import TerminalEmptyState from '~/ide/components/terminal/empty_state.vue';
@ -36,7 +36,7 @@ describe('IDE TerminalEmptyState', () => {
const img = wrapper.find('.svg-content img');
expect(img.exists()).toBe(true);
expect(img.attributes('src')).toEqual(TEST_PATH);
expect(img.attributes('src')).toBe(TEST_PATH);
});
it('when loading, shows loading icon', () => {
@ -71,24 +71,23 @@ describe('IDE TerminalEmptyState', () => {
},
});
button = wrapper.find('button');
button = wrapper.find(GlButton);
});
it('shows button', () => {
expect(button.text()).toEqual('Start Web Terminal');
expect(button.attributes('disabled')).toBeFalsy();
expect(button.text()).toBe('Start Web Terminal');
expect(button.props('disabled')).toBe(false);
});
it('emits start when button is clicked', () => {
expect(wrapper.emitted().start).toBeFalsy();
button.trigger('click');
expect(wrapper.emitted().start).toBeUndefined();
button.vm.$emit('click');
expect(wrapper.emitted().start).toHaveLength(1);
});
it('shows help path link', () => {
expect(wrapper.find('a').attributes('href')).toEqual(TEST_HELP_PATH);
expect(wrapper.find('a').attributes('href')).toBe(TEST_HELP_PATH);
});
});
@ -101,7 +100,7 @@ describe('IDE TerminalEmptyState', () => {
},
});
expect(wrapper.find('button').attributes('disabled')).not.toBe(null);
expect(wrapper.find('.bs-callout').element.innerHTML).toEqual(TEST_HTML_MESSAGE);
expect(wrapper.find(GlButton).props('disabled')).toBe(true);
expect(wrapper.find('.bs-callout').element.innerHTML).toBe(TEST_HTML_MESSAGE);
});
});

View File

@ -3,6 +3,8 @@ import {
canScrollUp,
canScrollDown,
parseBooleanDataAttributes,
isElementVisible,
isElementHidden,
} from '~/lib/utils/dom_utils';
const TEST_MARGIN = 5;
@ -160,4 +162,35 @@ describe('DOM Utils', () => {
});
});
});
describe.each`
offsetWidth | offsetHeight | clientRectsLength | visible
${0} | ${0} | ${0} | ${false}
${1} | ${0} | ${0} | ${true}
${0} | ${1} | ${0} | ${true}
${0} | ${0} | ${1} | ${true}
`(
'isElementVisible and isElementHidden',
({ offsetWidth, offsetHeight, clientRectsLength, visible }) => {
const element = {
offsetWidth,
offsetHeight,
getClientRects: () => new Array(clientRectsLength),
};
const paramDescription = `offsetWidth=${offsetWidth}, offsetHeight=${offsetHeight}, and getClientRects().length=${clientRectsLength}`;
describe('isElementVisible', () => {
it(`returns ${visible} when ${paramDescription}`, () => {
expect(isElementVisible(element)).toBe(visible);
});
});
describe('isElementHidden', () => {
it(`returns ${!visible} when ${paramDescription}`, () => {
expect(isElementHidden(element)).toBe(!visible);
});
});
},
);
});

View File

@ -0,0 +1,129 @@
import { shallowMount } from '@vue/test-utils';
import { GlPopover } from '@gitlab/ui';
import { useMockMutationObserver } from 'helpers/mock_dom_observer';
import Popovers from '~/popovers/components/popovers.vue';
describe('popovers/components/popovers.vue', () => {
const { trigger: triggerMutate, observersCount } = useMockMutationObserver();
let wrapper;
const buildWrapper = (...targets) => {
wrapper = shallowMount(Popovers);
wrapper.vm.addPopovers(targets);
return wrapper.vm.$nextTick();
};
const createPopoverTarget = (options = {}) => {
const target = document.createElement('button');
const dataset = {
title: 'default title',
content: 'some content',
...options,
};
Object.entries(dataset).forEach(([key, value]) => {
target.dataset[key] = value;
});
document.body.appendChild(target);
return target;
};
const allPopovers = () => wrapper.findAll(GlPopover);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('addPopovers', () => {
it('attaches popovers to the targets specified', async () => {
const target = createPopoverTarget();
await buildWrapper(target);
expect(wrapper.find(GlPopover).props('target')).toBe(target);
});
it('does not attach a popover twice to the same element', async () => {
const target = createPopoverTarget();
buildWrapper(target);
wrapper.vm.addPopovers([target]);
await wrapper.vm.$nextTick();
expect(wrapper.findAll(GlPopover)).toHaveLength(1);
});
it('supports HTML content', async () => {
const content = 'content with <b>HTML</b>';
await buildWrapper(
createPopoverTarget({
content,
html: true,
}),
);
const html = wrapper.find(GlPopover).html();
expect(html).toContain(content);
});
it.each`
option | value
${'placement'} | ${'bottom'}
${'triggers'} | ${'manual'}
`('sets $option to $value when data-$option is set in target', async ({ option, value }) => {
await buildWrapper(createPopoverTarget({ [option]: value }));
expect(wrapper.find(GlPopover).props(option)).toBe(value);
});
});
describe('dispose', () => {
it('removes all popovers when elements is nil', async () => {
await buildWrapper(createPopoverTarget(), createPopoverTarget());
wrapper.vm.dispose();
await wrapper.vm.$nextTick();
expect(allPopovers()).toHaveLength(0);
});
it('removes the popovers that target the elements specified', async () => {
const target = createPopoverTarget();
await buildWrapper(target, createPopoverTarget());
wrapper.vm.dispose(target);
await wrapper.vm.$nextTick();
expect(allPopovers()).toHaveLength(1);
});
});
describe('observe', () => {
it('removes popover when target is removed from the document', async () => {
const target = createPopoverTarget();
await buildWrapper(target);
wrapper.vm.addPopovers([target, createPopoverTarget()]);
await wrapper.vm.$nextTick();
triggerMutate(document.body, {
entry: { removedNodes: [target] },
options: { childList: true },
});
await wrapper.vm.$nextTick();
expect(allPopovers()).toHaveLength(1);
});
});
it('disconnects mutation observer on beforeDestroy', async () => {
await buildWrapper(createPopoverTarget());
expect(observersCount()).toBe(1);
wrapper.destroy();
expect(observersCount()).toBe(0);
});
});

View File

@ -0,0 +1,104 @@
import { initPopovers, dispose, destroy } from '~/popovers';
describe('popovers/index.js', () => {
let popoversApp;
const createPopoverTarget = (trigger = 'hover') => {
const target = document.createElement('button');
const dataset = {
title: 'default title',
content: 'some content',
toggle: 'popover',
trigger,
};
Object.entries(dataset).forEach(([key, value]) => {
target.dataset[key] = value;
});
document.body.appendChild(target);
return target;
};
const buildPopoversApp = () => {
popoversApp = initPopovers('[data-toggle="popover"]');
};
const triggerEvent = (target, eventName = 'mouseenter') => {
const event = new Event(eventName);
target.dispatchEvent(event);
};
afterEach(() => {
document.body.innerHTML = '';
destroy();
});
describe('initPopover', () => {
it('attaches a GlPopover for the elements specified in the selector', async () => {
const target = createPopoverTarget();
buildPopoversApp();
triggerEvent(target);
await popoversApp.$nextTick();
const html = document.querySelector('.gl-popover').innerHTML;
expect(document.querySelector('.gl-popover')).not.toBe(null);
expect(html).toContain('default title');
expect(html).toContain('some content');
});
it('supports triggering a popover via custom events', async () => {
const trigger = 'click';
const target = createPopoverTarget(trigger);
buildPopoversApp();
triggerEvent(target, trigger);
await popoversApp.$nextTick();
expect(document.querySelector('.gl-popover')).not.toBe(null);
expect(document.querySelector('.gl-popover').innerHTML).toContain('default title');
});
it('inits popovers on targets added after content load', async () => {
buildPopoversApp();
expect(document.querySelector('.gl-popover')).toBe(null);
const trigger = 'click';
const target = createPopoverTarget(trigger);
triggerEvent(target, trigger);
await popoversApp.$nextTick();
expect(document.querySelector('.gl-popover')).not.toBe(null);
});
});
describe('dispose', () => {
it('removes popovers that target the elements specified', async () => {
const fakeTarget = createPopoverTarget();
const target = createPopoverTarget();
buildPopoversApp();
triggerEvent(target);
triggerEvent(createPopoverTarget());
await popoversApp.$nextTick();
expect(document.querySelectorAll('.gl-popover')).toHaveLength(2);
dispose([fakeTarget]);
await popoversApp.$nextTick();
expect(document.querySelectorAll('.gl-popover')).toHaveLength(2);
dispose([target]);
await popoversApp.$nextTick();
expect(document.querySelectorAll('.gl-popover')).toHaveLength(1);
});
});
});

View File

@ -1,13 +1,7 @@
import Vue from 'vue';
import Mousetrap from 'mousetrap';
import mountComponent from 'helpers/vue_mount_component_helper';
import headerComponent from '~/vue_merge_request_widget/components/mr_widget_header.vue';
jest.mock('mousetrap', () => ({
bind: jest.fn(),
unbind: jest.fn(),
}));
describe('MRWidgetHeader', () => {
let vm;
let Component;
@ -136,35 +130,6 @@ describe('MRWidgetHeader', () => {
it('renders target branch', () => {
expect(vm.$el.querySelector('.js-target-branch').textContent.trim()).toEqual('master');
});
describe('keyboard shortcuts', () => {
it('binds a keyboard shortcut handler to the "b" key', () => {
expect(Mousetrap.bind).toHaveBeenCalledWith('b', expect.any(Function));
});
it('triggers a click on the "copy to clipboard" button when the handler is executed', () => {
const testClickHandler = jest.fn();
vm.$refs.copyBranchNameButton.$el.addEventListener('click', testClickHandler);
// Get a reference to the function that was assigned to the "b" shortcut key.
const shortcutHandler = Mousetrap.bind.mock.calls[0][1];
expect(testClickHandler).not.toHaveBeenCalled();
// Simulate Mousetrap calling the function.
shortcutHandler();
expect(testClickHandler).toHaveBeenCalledTimes(1);
});
it('unbinds the keyboard shortcut when the component is destroyed', () => {
expect(Mousetrap.unbind).not.toHaveBeenCalled();
vm.$destroy();
expect(Mousetrap.unbind).toHaveBeenCalledWith('b');
});
});
});
describe('with an open merge request', () => {

View File

@ -82,16 +82,6 @@ RSpec.describe PageLayoutHelper do
expect(helper.page_image).to match_asset_path 'assets/gitlab_logo.png'
end
end
context 'if avatar_with_host is disabled' do
it "#{type.titlecase} does not generate avatar full url" do
stub_feature_flags(avatar_with_host: false)
assign(type, object)
expect(helper.page_image).to eq object.avatar_url(only_path: true)
end
end
end
end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20190924152703_migrate_issue_trackers_data.rb')
require_migration!('migrate_issue_trackers_data')
RSpec.describe MigrateIssueTrackersData do
let(:services) { table(:services) }

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20191015154408_drop_merge_requests_require_code_owner_approval_from_projects.rb')
require_migration!('drop_merge_requests_require_code_owner_approval_from_projects')
RSpec.describe DropMergeRequestsRequireCodeOwnerApprovalFromProjects do
let(:projects_table) { table(:projects) }

View File

@ -13,7 +13,8 @@ RSpec.describe 'Robots.txt Requests', :aggregate_failures do
it 'allows the requests' do
requests = [
'/users/sign_in'
'/users/sign_in',
'/namespace/subnamespace/design.gitlab.com'
]
requests.each do |request|
@ -60,7 +61,8 @@ RSpec.describe 'Robots.txt Requests', :aggregate_failures do
'/foo/bar/protected_branches',
'/foo/bar/uploads/foo',
'/foo/bar/project_members',
'/foo/bar/settings'
'/foo/bar/settings',
'/namespace/subnamespace/design.gitlab.com/settings'
]
requests.each do |request|

View File

@ -3,23 +3,42 @@
require 'find'
class RequireMigration
MIGRATION_FOLDERS = %w(db/migrate db/post_migrate ee/db/geo/migrate ee/db/geo/post_migrate).freeze
class AutoLoadError < RuntimeError
MESSAGE = "Can not find any migration file for `%{file_name}`!\n" \
"You can try to provide the migration file name manually."
def initialize(file_name)
message = format(MESSAGE, file_name: file_name)
super(message)
end
end
FOSS_MIGRATION_FOLDERS = %w[db/migrate db/post_migrate].freeze
ALL_MIGRATION_FOLDERS = (FOSS_MIGRATION_FOLDERS + %w[ee/db/geo/migrate ee/db/geo/post_migrate]).freeze
SPEC_FILE_PATTERN = /.+\/(?<file_name>.+)_spec\.rb/.freeze
class << self
def require_migration!(file_name)
file_paths = search_migration_file(file_name)
raise AutoLoadError.new(file_name) unless file_paths.first
require file_paths.first
end
def search_migration_file(file_name)
MIGRATION_FOLDERS.flat_map do |path|
migration_folders.flat_map do |path|
migration_path = Rails.root.join(path).to_s
Find.find(migration_path).grep(/\d+_#{file_name}\.rb/)
end
end
private
def migration_folders
Gitlab.ee? ? ALL_MIGRATION_FOLDERS : FOSS_MIGRATION_FOLDERS
end
end
end

View File

@ -1,72 +1,245 @@
# frozen_string_literal: true
RSpec.shared_examples 'variable list' do
it 'shows list of variables' do
page.within('.js-ci-variable-list-section') do
expect(first('.js-ci-variable-input-key').value).to eq(variable.key)
it 'shows a list of variables' do
page.within('.ci-variable-table') do
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq(variable.key)
end
end
it 'adds new CI variable' do
page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('key')
find('.js-ci-variable-input-value').set('key_value')
it 'adds a new CI variable' do
click_button('Add Variable')
fill_variable('key', 'key_value') do
click_button('Add variable')
end
click_button('Save variables')
wait_for_requests
visit page_path
# We check the first row because it re-sorts to alphabetical order on refresh
page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do
expect(find('.js-ci-variable-input-key').value).to eq('key')
expect(find('.js-ci-variable-input-value', visible: false).value).to eq('key_value')
page.within('.ci-variable-table') do
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq('key')
end
end
it 'adds a new protected variable' do
page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('key')
find('.js-ci-variable-input-value').set('key_value')
click_button('Add Variable')
expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('true')
fill_variable('key', 'key_value') do
click_button('Add variable')
end
click_button('Save variables')
wait_for_requests
visit page_path
# We check the first row because it re-sorts to alphabetical order on refresh
page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do
expect(find('.js-ci-variable-input-key').value).to eq('key')
expect(find('.js-ci-variable-input-value', visible: false).value).to eq('key_value')
expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('true')
page.within('.ci-variable-table') do
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq('key')
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Protected"] svg[data-testid="mobile-issue-close-icon"]')).to be_present
end
end
it 'defaults to unmasked' do
page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('key')
find('.js-ci-variable-input-value').set('key_value')
click_button('Add Variable')
expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('false')
fill_variable('key', 'key_value') do
click_button('Add variable')
end
click_button('Save variables')
wait_for_requests
visit page_path
# We check the first row because it re-sorts to alphabetical order on refresh
page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do
expect(find('.js-ci-variable-input-key').value).to eq('key')
expect(find('.js-ci-variable-input-value', visible: false).value).to eq('key_value')
expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('false')
page.within('.ci-variable-table') do
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq('key')
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Masked"] svg[data-testid="close-icon"]')).to be_present
end
end
it 'reveals and hides variables' do
page.within('.ci-variable-table') do
expect(first('.js-ci-variable-row td[data-label="Key"]').text).to eq(variable.key)
expect(page).to have_content('*' * 17)
click_button('Reveal value')
expect(first('.js-ci-variable-row td[data-label="Key"]').text).to eq(variable.key)
expect(first('.js-ci-variable-row td[data-label="Value"]').text).to eq(variable.value)
expect(page).not_to have_content('*' * 17)
click_button('Hide value')
expect(first('.js-ci-variable-row td[data-label="Key"]').text).to eq(variable.key)
expect(page).to have_content('*' * 17)
end
end
it 'deletes a variable' do
expect(page).to have_selector('.js-ci-variable-row', count: 1)
page.within('.ci-variable-table') do
click_button('Edit')
end
page.within('#add-ci-variable') do
click_button('Delete variable')
end
wait_for_requests
expect(first('.js-ci-variable-row').text).to eq('There are no variables yet.')
end
it 'edits a variable' do
page.within('.ci-variable-table') do
click_button('Edit')
end
page.within('#add-ci-variable') do
find('[data-qa-selector="ci_variable_key_field"] input').set('new_key')
click_button('Update variable')
end
wait_for_requests
expect(first('.js-ci-variable-row td[data-label="Key"]').text).to eq('new_key')
end
it 'edits a variable to be unmasked' do
page.within('.ci-variable-table') do
click_button('Edit')
end
page.within('#add-ci-variable') do
find('[data-testid="ci-variable-protected-checkbox"]').click
find('[data-testid="ci-variable-masked-checkbox"]').click
click_button('Update variable')
end
wait_for_requests
page.within('.ci-variable-table') do
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Masked"] svg[data-testid="close-icon"]')).to be_present
end
end
it 'edits a variable to be masked' do
page.within('.ci-variable-table') do
click_button('Edit')
end
page.within('#add-ci-variable') do
find('[data-testid="ci-variable-masked-checkbox"]').click
click_button('Update variable')
end
wait_for_requests
page.within('.ci-variable-table') do
click_button('Edit')
end
page.within('#add-ci-variable') do
find('[data-testid="ci-variable-masked-checkbox"]').click
click_button('Update variable')
end
page.within('.ci-variable-table') do
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Masked"] svg[data-testid="mobile-issue-close-icon"]')).to be_present
end
end
it 'shows a validation error box about duplicate keys' do
click_button('Add Variable')
fill_variable('key', 'key_value') do
click_button('Add variable')
end
wait_for_requests
click_button('Add Variable')
fill_variable('key', 'key_value') do
click_button('Add variable')
end
wait_for_requests
expect(find('.flash-container')).to be_present
expect(find('.flash-text').text).to have_content('Variables key (key) has already been taken')
end
it 'prevents a variable to be added if no values are provided when a variable is set to masked' do
click_button('Add Variable')
page.within('#add-ci-variable') do
find('[data-qa-selector="ci_variable_key_field"] input').set('empty_mask_key')
find('[data-testid="ci-variable-protected-checkbox"]').click
find('[data-testid="ci-variable-masked-checkbox"]').click
expect(find_button('Add variable', disabled: true)).to be_present
end
end
it 'shows validation error box about unmaskable values' do
click_button('Add Variable')
fill_variable('empty_mask_key', '???', protected: true, masked: true) do
expect(page).to have_content('This variable can not be masked')
expect(find_button('Add variable', disabled: true)).to be_present
end
end
it 'handles multiple edits and a deletion' do
# Create two variables
click_button('Add Variable')
fill_variable('akey', 'akeyvalue') do
click_button('Add variable')
end
wait_for_requests
click_button('Add Variable')
fill_variable('zkey', 'zkeyvalue') do
click_button('Add variable')
end
wait_for_requests
expect(page).to have_selector('.js-ci-variable-row', count: 3)
# Remove the `akey` variable
page.within('.ci-variable-table') do
page.within('.js-ci-variable-row:first-child') do
click_button('Edit')
end
end
page.within('#add-ci-variable') do
click_button('Delete variable')
end
wait_for_requests
# Add another variable
click_button('Add Variable')
fill_variable('ckey', 'ckeyvalue') do
click_button('Add variable')
end
wait_for_requests
# expect to find 3 rows of variables in alphabetical order
expect(page).to have_selector('.js-ci-variable-row', count: 3)
rows = all('.js-ci-variable-row')
expect(rows[0].find('td[data-label="Key"]').text).to eq('ckey')
expect(rows[1].find('td[data-label="Key"]').text).to eq('test_key')
expect(rows[2].find('td[data-label="Key"]').text).to eq('zkey')
end
context 'defaults to the application setting' do
context 'application setting is true' do
before do
@ -76,13 +249,11 @@ RSpec.shared_examples 'variable list' do
end
it 'defaults to protected' do
page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('key')
click_button('Add Variable')
page.within('#add-ci-variable') do
expect(find('[data-testid="ci-variable-protected-checkbox"]')).to be_checked
end
values = all('.js-ci-variable-input-protected', visible: false).map(&:value)
expect(values).to eq %w(false true true)
end
it 'shows a message regarding the changed default' do
@ -98,13 +269,11 @@ RSpec.shared_examples 'variable list' do
end
it 'defaults to unprotected' do
page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('key')
click_button('Add Variable')
page.within('#add-ci-variable') do
expect(find('[data-testid="ci-variable-protected-checkbox"]')).not_to be_checked
end
values = all('.js-ci-variable-input-protected', visible: false).map(&:value)
expect(values).to eq %w(false false false)
end
it 'does not show a message regarding the default' do
@ -113,275 +282,14 @@ RSpec.shared_examples 'variable list' do
end
end
it 'reveals and hides variables' do
page.within('.js-ci-variable-list-section') do
expect(first('.js-ci-variable-input-key').value).to eq(variable.key)
expect(first('.js-ci-variable-input-value', visible: false).value).to eq(variable.value)
expect(page).to have_content('*' * 17)
click_button('Reveal value')
expect(first('.js-ci-variable-input-key').value).to eq(variable.key)
expect(first('.js-ci-variable-input-value').value).to eq(variable.value)
expect(page).not_to have_content('*' * 17)
click_button('Hide value')
expect(first('.js-ci-variable-input-key').value).to eq(variable.key)
expect(first('.js-ci-variable-input-value', visible: false).value).to eq(variable.value)
expect(page).to have_content('*' * 17)
end
end
it 'deletes variable' do
page.within('.js-ci-variable-list-section') do
expect(page).to have_selector('.js-row', count: 2)
first('.js-row-remove-button').click
click_button('Save variables')
wait_for_requests
expect(page).to have_selector('.js-row', count: 1)
end
end
it 'edits variable' do
page.within('.js-ci-variable-list-section') do
click_button('Reveal value')
page.within('.js-row:nth-child(2)') do
find('.js-ci-variable-input-key').set('new_key')
find('.js-ci-variable-input-value').set('new_value')
end
click_button('Save variables')
wait_for_requests
visit page_path
page.within('.js-row:nth-child(2)') do
expect(find('.js-ci-variable-input-key').value).to eq('new_key')
expect(find('.js-ci-variable-input-value', visible: false).value).to eq('new_value')
end
end
end
it 'edits variable to be protected' do
# Create the unprotected variable
page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('unprotected_key')
find('.js-ci-variable-input-value').set('unprotected_value')
find('.ci-variable-protected-item .js-project-feature-toggle').click
expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('false')
end
click_button('Save variables')
wait_for_requests
visit page_path
# We check the first row because it re-sorts to alphabetical order on refresh
page.within('.js-ci-variable-list-section .js-row:nth-child(3)') do
find('.ci-variable-protected-item .js-project-feature-toggle').click
expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('true')
end
click_button('Save variables')
wait_for_requests
visit page_path
# We check the first row because it re-sorts to alphabetical order on refresh
page.within('.js-ci-variable-list-section .js-row:nth-child(3)') do
expect(find('.js-ci-variable-input-key').value).to eq('unprotected_key')
expect(find('.js-ci-variable-input-value', visible: false).value).to eq('unprotected_value')
expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('true')
end
end
it 'edits variable to be unprotected' do
# Create the protected variable
page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('protected_key')
find('.js-ci-variable-input-value').set('protected_value')
expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('true')
end
click_button('Save variables')
wait_for_requests
visit page_path
page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do
find('.ci-variable-protected-item .js-project-feature-toggle').click
expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('false')
end
click_button('Save variables')
wait_for_requests
visit page_path
page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do
expect(find('.js-ci-variable-input-key').value).to eq('protected_key')
expect(find('.js-ci-variable-input-value', visible: false).value).to eq('protected_value')
expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('false')
end
end
it 'edits variable to be unmasked' do
page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('unmasked_key')
find('.js-ci-variable-input-value').set('unmasked_value')
expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('false')
find('.ci-variable-masked-item .js-project-feature-toggle').click
expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('true')
end
click_button('Save variables')
wait_for_requests
visit page_path
page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do
expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('true')
find('.ci-variable-masked-item .js-project-feature-toggle').click
expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('false')
end
click_button('Save variables')
wait_for_requests
visit page_path
page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do
expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('false')
end
end
it 'edits variable to be masked' do
page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('masked_key')
find('.js-ci-variable-input-value').set('masked_value')
expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('false')
find('.ci-variable-masked-item .js-project-feature-toggle').click
expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('true')
end
click_button('Save variables')
wait_for_requests
visit page_path
page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do
expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('true')
end
end
it 'handles multiple edits and deletion in the middle' do
page.within('.js-ci-variable-list-section') do
# Create 2 variables
page.within('.js-row:last-child') do
find('.js-ci-variable-input-key').set('akey')
find('.js-ci-variable-input-value').set('akeyvalue')
end
page.within('.js-row:last-child') do
find('.js-ci-variable-input-key').set('zkey')
find('.js-ci-variable-input-value').set('zkeyvalue')
end
click_button('Save variables')
wait_for_requests
expect(page).to have_selector('.js-row', count: 4)
# Remove the `akey` variable
page.within('.js-row:nth-child(3)') do
first('.js-row-remove-button').click
end
# Add another variable
page.within('.js-row:last-child') do
find('.js-ci-variable-input-key').set('ckey')
find('.js-ci-variable-input-value').set('ckeyvalue')
end
click_button('Save variables')
wait_for_requests
visit page_path
# Expect to find 3 variables(4 rows) in alphbetical order
expect(page).to have_selector('.js-row', count: 4)
row_keys = all('.js-ci-variable-input-key')
expect(row_keys[0].value).to eq('ckey')
expect(row_keys[1].value).to eq('test_key')
expect(row_keys[2].value).to eq('zkey')
expect(row_keys[3].value).to eq('')
end
end
it 'shows validation error box about duplicate keys' do
page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('samekey')
find('.js-ci-variable-input-value').set('value123')
end
page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('samekey')
find('.js-ci-variable-input-value').set('value456')
end
click_button('Save variables')
wait_for_requests
expect(all('.js-ci-variable-list-section .js-ci-variable-error-box ul li').count).to eq(1)
# We check the first row because it re-sorts to alphabetical order on refresh
page.within('.js-ci-variable-list-section') do
expect(find('.js-ci-variable-error-box')).to have_content(/Validation failed Variables have duplicate values \(.+\)/)
end
end
it 'shows validation error box about masking empty values' do
page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('empty_value')
find('.js-ci-variable-input-value').set('')
find('.ci-variable-masked-item .js-project-feature-toggle').click
end
click_button('Save variables')
wait_for_requests
page.within('.js-ci-variable-list-section') do
expect(all('.js-ci-variable-error-box ul li').count).to eq(1)
expect(find('.js-ci-variable-error-box')).to have_content(/Validation failed Variables value is invalid/)
end
end
it 'shows validation error box about unmaskable values' do
page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('unmaskable_value')
find('.js-ci-variable-input-value').set('???')
find('.ci-variable-masked-item .js-project-feature-toggle').click
end
click_button('Save variables')
wait_for_requests
page.within('.js-ci-variable-list-section') do
expect(all('.js-ci-variable-error-box ul li').count).to eq(1)
expect(find('.js-ci-variable-error-box')).to have_content(/Validation failed Variables value is invalid/)
def fill_variable(key, value, protected: false, masked: false)
page.within('#add-ci-variable') do
find('[data-qa-selector="ci_variable_key_field"] input').set(key)
find('[data-qa-selector="ci_variable_value_field"]').set(value) if value.present?
find('[data-testid="ci-variable-protected-checkbox"]').click if protected
find('[data-testid="ci-variable-masked-checkbox"]').click if masked
yield
end
end
end