Commit 1c265aac authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '327638-add-status-checks-form' into 'master'

Create status checks form and branch selection

See merge request gitlab-org/gitlab!61700
parents 0c2ac180 79b6a429
<script>
import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
import { debounce } from 'lodash';
import Api from 'ee/api';
import { __ } from '~/locale';
import { BRANCH_FETCH_DELAY, ANY_BRANCH } from '../constants';
export default {
components: {
GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
},
props: {
projectId: {
type: String,
required: true,
},
selectedBranches: {
type: Array,
required: false,
default: () => [],
},
isInvalid: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
branches: [],
initialLoading: false,
searching: false,
searchTerm: '',
selected: this.selectedBranches[0] || ANY_BRANCH,
};
},
mounted() {
this.initialLoading = true;
this.fetchBranches()
// Errors are handled by fetchBranches
.catch(() => {})
.finally(() => {
this.initialLoading = false;
});
},
methods: {
fetchBranches(term) {
this.searching = true;
const excludeAnyBranch = term && !term.toLowerCase().includes('any');
return Api.projectProtectedBranches(this.projectId, term)
.then((branches) => {
this.$emit('apiError', false);
this.branches = excludeAnyBranch ? branches : [ANY_BRANCH, ...branches];
})
.catch((error) => {
this.$emit('apiError', true, error);
this.branches = excludeAnyBranch ? [] : [ANY_BRANCH];
})
.finally(() => {
this.searching = false;
});
},
search: debounce(function debouncedSearch() {
this.fetchBranches(this.searchTerm);
}, BRANCH_FETCH_DELAY),
isSelectedBranch(id) {
return this.selected.id === id;
},
onSelect(branch) {
this.selected = branch;
this.$emit('input', branch);
},
branchNameClass(id) {
return {
monospace: id !== null,
};
},
},
i18n: {
header: __('Select branch'),
},
};
</script>
<template>
<gl-dropdown
:class="{ 'is-invalid': isInvalid }"
class="gl-w-full gl-dropdown-menu-full-width"
:text="selected.name"
:loading="initialLoading"
:header-text="$options.i18n.header"
>
<template #header>
<gl-search-box-by-type v-model="searchTerm" :is-loading="searching" @input="search" />
</template>
<gl-dropdown-item
v-for="branch in branches"
:key="branch.id"
:is-check-item="true"
:is-checked="isSelectedBranch(branch.id)"
@click="onSelect(branch)"
>
<span :class="branchNameClass(branch.id)">{{ branch.name }}</span>
</gl-dropdown-item>
</gl-dropdown>
</template>
<script>
import { GlAlert, GlFormGroup, GlFormInput } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { isEqual, isNumber } from 'lodash';
import { isSafeURL } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
import {
ANY_BRANCH,
EMPTY_STATUS_CHECK,
NAME_TAKEN_SERVER_ERROR,
URL_TAKEN_SERVER_ERROR,
} from '../constants';
import BranchesSelect from './branches_select.vue';
export default {
components: {
BranchesSelect,
GlAlert,
GlFormGroup,
GlFormInput,
},
props: {
projectId: {
type: String,
required: true,
},
serverValidationErrors: {
type: Array,
required: false,
default: () => [],
},
showValidation: {
type: Boolean,
required: false,
default: false,
},
statusCheck: {
type: Object,
required: false,
default: () => EMPTY_STATUS_CHECK,
},
},
data() {
const { protectedBranches, name, externalUrl: url } = this.statusCheck;
return {
branches: protectedBranches,
branchesToAdd: [],
branchesApiFailed: false,
name,
url,
};
},
computed: {
formData() {
const { branches, name, url } = this;
return {
branches: branches.map(({ id }) => id),
name,
url,
};
},
isValid() {
return this.isValidBranches && this.isValidName && this.isValidUrl;
},
isValidBranches() {
return this.branches.every((branch) => isEqual(branch, ANY_BRANCH) || isNumber(branch?.id));
},
isValidName() {
return Boolean(this.name);
},
isValidUrl() {
return Boolean(this.url) && isSafeURL(this.url);
},
branchesState() {
return !this.showValidation || this.isValidBranches;
},
nameState() {
return (
!this.showValidation ||
(this.isValidName && !this.serverValidationErrors.includes(NAME_TAKEN_SERVER_ERROR))
);
},
urlState() {
return (
!this.showValidation ||
(this.isValidUrl && !this.serverValidationErrors.includes(URL_TAKEN_SERVER_ERROR))
);
},
invalidNameMessage() {
if (this.serverValidationErrors.includes(NAME_TAKEN_SERVER_ERROR)) {
return this.$options.i18n.validations.nameTaken;
}
return this.$options.i18n.validations.nameMissing;
},
invalidUrlMessage() {
if (this.serverValidationErrors.includes(URL_TAKEN_SERVER_ERROR)) {
return this.$options.i18n.validations.urlTaken;
}
return this.$options.i18n.validations.invalidUrl;
},
},
watch: {
branchesToAdd(value) {
this.branches = value ? [value] : [];
},
},
methods: {
setBranchApiError(hasErrored, error) {
if (!this.branchesApiFailed && error) {
Sentry.captureException(error);
}
this.branchesApiFailed = hasErrored;
},
},
i18n: {
form: {
addStatusChecks: s__('StatusCheck|API to check'),
statusChecks: s__('StatusCheck|Status to check'),
statusChecksDescription: s__(
'StatusCheck|Invoke an external API as part of the pipeline process.',
),
nameLabel: s__('StatusCheck|Service name'),
nameDescription: s__('StatusCheck|Examples: QA, Security.'),
protectedBranchLabel: s__('StatusCheck|Target branch'),
protectedBranchDescription: s__(
'StatusCheck|Apply this status check to any branch or a specific protected branch.',
),
},
validations: {
branchesRequired: __('Please select a valid target branch.'),
branchesApiFailure: __('Unable to fetch branches list, please close the form and try again'),
nameTaken: __('Name is already taken.'),
nameMissing: __('Please provide a name.'),
urlTaken: s__('StatusCheck|External API is already in use by another status check.'),
invalidUrl: __('Please provide a valid URL.'),
},
},
};
</script>
<template>
<div>
<gl-alert v-if="branchesApiFailed" class="gl-mb-5" :dismissible="false" variant="danger">
{{ $options.i18n.validations.branchesApiFailure }}
</gl-alert>
<form novalidate>
<gl-form-group
:label="$options.i18n.form.nameLabel"
:description="$options.i18n.form.nameDescription"
:state="nameState"
:invalid-feedback="invalidNameMessage"
data-testid="name-group"
>
<gl-form-input
v-model="name"
:state="nameState"
data-qa-selector="rule_name_field"
data-testid="name"
/>
</gl-form-group>
<gl-form-group
:label="$options.i18n.form.addStatusChecks"
:description="$options.i18n.form.statusChecksDescription"
:state="urlState"
:invalid-feedback="invalidUrlMessage"
data-testid="url-group"
>
<gl-form-input
v-model="url"
:state="urlState"
type="url"
:placeholder="`https://api.gitlab.com/`"
data-qa-selector="external_url_field"
data-testid="url"
/>
</gl-form-group>
<gl-form-group
:label="$options.i18n.form.protectedBranchLabel"
:description="$options.i18n.form.protectedBranchDescription"
:state="branchesState"
:invalid-feedback="$options.i18n.validations.branchesRequired"
data-testid="branches-group"
>
<branches-select
v-model="branchesToAdd"
:project-id="projectId"
:is-invalid="!branchesState"
:selected-branches="branches"
@apiError="setBranchApiError"
/>
</gl-form-group>
</form>
</div>
</template>
import { __ } from '~/locale';
export const BRANCH_FETCH_DELAY = 250;
export const ANY_BRANCH = {
id: null,
name: __('Any branch'),
};
export const EMPTY_STATUS_CHECK = {
name: '',
protectedBranches: [],
url: '',
};
export const URL_TAKEN_SERVER_ERROR = 'External url has already been taken';
export const NAME_TAKEN_SERVER_ERROR = 'Name has already been taken';
import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import Api from 'ee/api';
import BranchesSelect from 'ee/status_checks/components/branches_select.vue';
import waitForPromises from 'helpers/wait_for_promises';
import {
TEST_DEFAULT_BRANCH,
TEST_BRANCHES_SELECTIONS,
TEST_PROJECT_ID,
TEST_PROTECTED_BRANCHES,
} from '../mock_data';
const branchNames = () => TEST_BRANCHES_SELECTIONS.map((branch) => branch.name);
const protectedBranchNames = () => TEST_PROTECTED_BRANCHES.map((branch) => branch.name);
const error = new Error('Something went wrong');
describe('Branches Select', () => {
let wrapper;
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findSearch = () => wrapper.findComponent(GlSearchBoxByType);
const createComponent = (props = {}, mountFn = shallowMount) => {
wrapper = mountFn(BranchesSelect, {
propsData: {
projectId: '1',
...props,
},
});
};
beforeEach(() => {
jest
.spyOn(Api, 'projectProtectedBranches')
.mockReturnValue(Promise.resolve(TEST_PROTECTED_BRANCHES));
});
afterEach(() => {
wrapper.destroy();
});
describe('Initialization', () => {
it('renders dropdown', async () => {
createComponent();
await waitForPromises();
expect(findDropdown().exists()).toBe(true);
});
it('renders dropdown with invalid class if is invalid', async () => {
createComponent({ isInvalid: true });
await waitForPromises();
expect(findDropdown().classes('is-invalid')).toBe(true);
});
it('sets the initially selected item', async () => {
createComponent({
selectedBranches: [
{
id: 1,
name: 'main',
},
],
});
await waitForPromises();
expect(findDropdown().props('text')).toBe('main');
expect(
findDropdownItems()
.filter((item) => item.text() === 'main')
.at(0)
.props('isChecked'),
).toBe(true);
});
it('displays all the protected branches and any branch', async () => {
createComponent();
await nextTick();
expect(findDropdown().props('loading')).toBe(true);
await waitForPromises();
expect(wrapper.emitted().apiError).toStrictEqual([[false]]);
expect(findDropdownItems()).toHaveLength(branchNames().length);
expect(findDropdown().props('loading')).toBe(false);
});
describe('when fetching the branch list fails', () => {
beforeEach(() => {
jest.spyOn(Api, 'projectProtectedBranches').mockRejectedValueOnce(error);
createComponent({});
});
it('emits the `apiError` event', () => {
expect(wrapper.emitted().apiError).toStrictEqual([[true, error]]);
});
it('returns just the any branch dropdown items', () => {
expect(findDropdownItems()).toHaveLength(1);
expect(findDropdownItems().at(0).text()).toBe(TEST_DEFAULT_BRANCH.name);
});
});
});
describe('with search term', () => {
beforeEach(() => {
createComponent({}, mount);
return waitForPromises();
});
it('fetches protected branches with search term', async () => {
const term = 'lorem';
findSearch().vm.$emit('input', term);
await nextTick();
expect(findSearch().props('isLoading')).toBe(true);
await waitForPromises();
expect(Api.projectProtectedBranches).toHaveBeenCalledWith(TEST_PROJECT_ID, term);
expect(wrapper.emitted().apiError).toStrictEqual([[false], [false]]);
expect(findSearch().props('isLoading')).toBe(false);
});
it('fetches protected branches with no any branch if there is a search', async () => {
findSearch().vm.$emit('input', 'main');
await waitForPromises();
expect(findDropdownItems()).toHaveLength(protectedBranchNames().length);
});
it('fetches protected branches with any branch if search contains term "any"', async () => {
findSearch().vm.$emit('input', 'any');
await waitForPromises();
expect(findDropdownItems()).toHaveLength(branchNames().length);
});
describe('when fetching the branch list fails while searching', () => {
beforeEach(() => {
jest.spyOn(Api, 'projectProtectedBranches').mockRejectedValueOnce(error);
findSearch().vm.$emit('input', 'main');
return waitForPromises();
});
it('emits the `apiError` event', () => {
expect(wrapper.emitted().apiError).toStrictEqual([[false], [true, error]]);
});
it('returns no dropdown items', () => {
expect(findDropdownItems()).toHaveLength(0);
});
});
describe('when fetching the branch list fails while searching for the term "any"', () => {
beforeEach(() => {
jest.spyOn(Api, 'projectProtectedBranches').mockRejectedValueOnce(error);
findSearch().vm.$emit('input', 'any');
return waitForPromises();
});
it('emits the `apiError` event', () => {
expect(wrapper.emitted().apiError).toStrictEqual([[false], [true, error]]);
});
it('returns just the any branch dropdown item', () => {
expect(findDropdownItems()).toHaveLength(1);
expect(findDropdownItems().at(0).text()).toBe(TEST_DEFAULT_BRANCH.name);
});
});
});
it('when the branch is changed it sets the isChecked property and emits the input event', async () => {
createComponent();
await waitForPromises();
await findDropdownItems().at(1).vm.$emit('click');
expect(findDropdownItems().at(1).props('isChecked')).toBe(true);
expect(wrapper.emitted().input).toStrictEqual([[TEST_PROTECTED_BRANCHES[0]]]);
});
});
import { GlAlert, GlFormGroup, GlFormInput } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { nextTick } from 'vue';
import BranchesSelect from 'ee/status_checks/components/branches_select.vue';
import Form from 'ee/status_checks/components/form.vue';
import { NAME_TAKEN_SERVER_ERROR, URL_TAKEN_SERVER_ERROR } from 'ee/status_checks/constants';
import { stubComponent } from 'helpers/stub_component';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { TEST_PROTECTED_BRANCHES } from '../mock_data';
const projectId = '1';
const statusCheck = {
protectedBranches: TEST_PROTECTED_BRANCHES,
branches: TEST_PROTECTED_BRANCHES,
name: 'Foo',
externalUrl: 'https://foo.com',
};
const sentryError = new Error('Network error');
describe('Status checks form', () => {
let wrapper;
const createWrapper = (props = {}) => {
wrapper = shallowMountExtended(Form, {
propsData: { projectId, ...props },
stubs: {
GlFormGroup: stubComponent(GlFormGroup, {
props: ['state', 'invalidFeedback'],
}),
GlFormInput: stubComponent(GlFormInput, {
props: ['state', 'disabled', 'value'],
template: `<input />`,
}),
},
});
};
const findNameInput = () => wrapper.findByTestId('name');
const findNameValidation = () => wrapper.findByTestId('name-group');
const findBranchesSelect = () => wrapper.findComponent(BranchesSelect);
const findUrlInput = () => wrapper.findByTestId('url');
const findUrlValidation = () => wrapper.findByTestId('url-group');
const findBranchesValidation = () => wrapper.findByTestId('branches-group');
const findBranchesErrorAlert = () => wrapper.findComponent(GlAlert);
const findValidations = () => [
findNameValidation(),
findUrlValidation(),
findBranchesValidation(),
];
const inputsAreValid = () => findValidations().every((x) => x.props('state'));
afterEach(() => {
wrapper.destroy();
});
describe('initialization', () => {
it('shows empty inputs when no initial data is given', () => {
createWrapper();
expect(inputsAreValid()).toBe(true);
expect(findNameInput().props('value')).toBe('');
expect(findBranchesSelect().props('selectedBranches')).toStrictEqual([]);
expect(findUrlInput().props('value')).toBe(undefined);
});
it('shows filled inputs when initial data is given', () => {
createWrapper({ statusCheck });
expect(inputsAreValid()).toBe(true);
expect(findNameInput().props('value')).toBe(statusCheck.name);
expect(findBranchesSelect().props('selectedBranches')).toStrictEqual(statusCheck.branches);
expect(findUrlInput().props('value')).toBe(statusCheck.externalUrl);
});
});
describe('Validation', () => {
it('shows the validation messages if showValidation is passed', () => {
createWrapper({ showValidation: true, branches: ['abc'] });
expect(inputsAreValid()).toBe(false);
expect(findNameValidation().props('invalidFeedback')).toBe('Please provide a name.');
expect(findBranchesValidation().props('invalidFeedback')).toBe(
'Please select a valid target branch.',
);
expect(findUrlValidation().props('invalidFeedback')).toBe('Please provide a valid URL.');
});
it('shows the invalid URL error if the URL is unsafe', () => {
createWrapper({
showValidation: true,
statusCheck: { ...statusCheck, externalUrl: 'ftp://foo.com' },
});
expect(inputsAreValid()).toBe(false);
expect(findUrlValidation().props('invalidFeedback')).toBe('Please provide a valid URL.');
});
it('shows the serverValidationErrors if given', () => {
createWrapper({
showValidation: true,
serverValidationErrors: [NAME_TAKEN_SERVER_ERROR, URL_TAKEN_SERVER_ERROR],
});
expect(inputsAreValid()).toBe(false);
expect(findNameValidation().props('invalidFeedback')).toBe('Name is already taken.');
expect(findUrlValidation().props('invalidFeedback')).toBe(
'External API is already in use by another status check.',
);
});
it('does not show any errors if the values are valid', () => {
createWrapper({ showValidation: true, statusCheck });
expect(inputsAreValid()).toBe(true);
});
});
describe('Branches error alert', () => {
beforeEach(() => {
jest.spyOn(Sentry, 'captureException');
createWrapper();
});
it('sends the error to sentry', () => {
findBranchesSelect().vm.$emit('apiError', true, sentryError);
expect(Sentry.captureException.mock.calls[0][0]).toStrictEqual(sentryError);
});
it('shows the alert', async () => {
expect(findBranchesErrorAlert().exists()).toBe(false);
findBranchesSelect().vm.$emit('apiError', true, sentryError);
await nextTick();
expect(findBranchesErrorAlert().exists()).toBe(true);
});
it('hides the alert if the apiError is reset', async () => {
findBranchesSelect().vm.$emit('apiError', true, sentryError);
await nextTick();
expect(findBranchesErrorAlert().exists()).toBe(true);
findBranchesSelect().vm.$emit('apiError', false);
await nextTick();
expect(findBranchesErrorAlert().exists()).toBe(false);
});
it('only calls sentry once while the branches api is failing', () => {
findBranchesSelect().vm.$emit('apiError', true, sentryError);
findBranchesSelect().vm.$emit('apiError', true, sentryError);
expect(Sentry.captureException.mock.calls).toEqual([[sentryError]]);
});
});
});
export const TEST_DEFAULT_BRANCH = { name: 'Any branch' };
export const TEST_PROJECT_ID = '1';
export const TEST_PROTECTED_BRANCHES = [
{ id: 1, name: 'main' },
{ id: 2, name: 'development' },
];
export const TEST_BRANCHES_SELECTIONS = [TEST_DEFAULT_BRANCH, ...TEST_PROTECTED_BRANCHES];
......@@ -21619,6 +21619,9 @@ msgstr ""
msgid "Name has already been taken"
msgstr ""
msgid "Name is already taken."
msgstr ""
msgid "Name new label"
msgstr ""
......@@ -24633,12 +24636,18 @@ msgstr ""
msgid "Please provide a name"
msgstr ""
msgid "Please provide a name."
msgstr ""
msgid "Please provide a valid URL"
msgstr ""
msgid "Please provide a valid URL ending with .git"
msgstr ""
msgid "Please provide a valid URL."
msgstr ""
msgid "Please provide a valid YouTube URL or ID"
msgstr ""
......@@ -24675,6 +24684,9 @@ msgstr ""
msgid "Please select a valid target branch"
msgstr ""
msgid "Please select a valid target branch."
msgstr ""
msgid "Please select and add a member"
msgstr ""
......@@ -31045,12 +31057,24 @@ msgstr ""
msgid "StatusCheck|An error occurred fetching the status checks."
msgstr ""
msgid "StatusCheck|Apply this status check to any branch or a specific protected branch."
msgstr ""
msgid "StatusCheck|Check for a status response in Merge Requests. Failures do not block merges. %{link_start}Learn more%{link_end}."
msgstr ""
msgid "StatusCheck|Examples: QA, Security."
msgstr ""
msgid "StatusCheck|External API is already in use by another status check."
msgstr ""
msgid "StatusCheck|Invoke an external API as part of the approvals"
msgstr ""
msgid "StatusCheck|Invoke an external API as part of the pipeline process."
msgstr ""
msgid "StatusCheck|No status checks are defined yet."
msgstr ""
......@@ -31069,6 +31093,9 @@ msgstr ""
msgid "StatusCheck|Status to check"
msgstr ""
msgid "StatusCheck|Target branch"
msgstr ""
msgid "StatusCheck|You are about to remove the %{name} status check."
msgstr ""
......@@ -34609,6 +34636,9 @@ msgstr ""
msgid "Unable to fetch branch list for this project."
msgstr ""
msgid "Unable to fetch branches list, please close the form and try again"
msgstr ""
msgid "Unable to fetch unscanned projects"
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment