Commit 646d7c65 authored by Robert Hunt's avatar Robert Hunt Committed by Natalia Tepluhina

Add listing app to show status checks

parent 474cbba8
......@@ -4,6 +4,7 @@ import '~/pages/projects/edit';
import mountApprovals from 'ee/approvals/mount_project_settings';
import initMergeOptionSettings from 'ee/pages/projects/edit/merge_options';
import initProjectAdjournedDeleteButton from 'ee/projects/project_adjourned_delete_button';
import mountStatusChecks from 'ee/status_checks/mount';
import groupsSelect from '~/groups_select';
import UserCallout from '~/user_callout';
import UsersSelect from '~/users_select';
......@@ -14,6 +15,7 @@ groupsSelect();
new UserCallout({ className: 'js-mr-approval-callout' });
mountApprovals(document.getElementById('js-mr-approvals-settings'));
mountStatusChecks(document.getElementById('js-status-checks-settings'));
initProjectAdjournedDeleteButton();
initMergeOptionSettings();
<script>
import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlButton,
},
i18n: {
editButton: __('Edit'),
removeButton: __('Remove...'),
},
};
</script>
<template>
<div class="gl-display-flex gl-justify-content-end">
<gl-button data-testid="edit-btn">{{ $options.i18n.editButton }}</gl-button>
<gl-button class="gl-ml-3" data-testid="remove-btn">
{{ $options.i18n.removeButton }}
</gl-button>
</div>
</template>
<script>
import { __ } from '~/locale';
export default {
props: {
branches: {
type: Array,
required: false,
default: () => [],
},
},
computed: {
isAnyBranch() {
return !this.branches?.length;
},
branchName() {
return this.isAnyBranch ? __('Any branch') : this.branches[0].name;
},
},
};
</script>
<template>
<span :class="{ monospace: isAnyBranch }">{{ branchName }}</span>
</template>
<script>
import { GlButton, GlTable } from '@gitlab/ui';
import { mapState } from 'vuex';
import { __, s__ } from '~/locale';
import Actions from './actions.vue';
import Branch from './branch.vue';
const DEFAULT_TH_CLASSES =
'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!';
const thWidthClass = (width) => `gl-w-${width}p ${DEFAULT_TH_CLASSES}`;
export const i18n = {
addButton: s__('StatusCheck|Add status check'),
apiHeader: __('API'),
branchHeader: __('Target branch'),
emptyTableText: s__('StatusCheck|No status checks are defined yet.'),
nameHeader: s__('StatusCheck|Service name'),
};
export default {
components: {
Actions,
Branch,
GlButton,
GlTable,
},
computed: {
...mapState(['statusChecks']),
},
fields: [
{
key: 'name',
label: i18n.nameHeader,
thClass: thWidthClass(20),
},
{
key: 'externalUrl',
label: i18n.apiHeader,
thClass: thWidthClass(40),
},
{
key: 'protectedBranches',
label: i18n.branchHeader,
thClass: thWidthClass(20),
},
{
key: 'actions',
label: '',
thClass: DEFAULT_TH_CLASSES,
tdClass: 'gl-text-right',
},
],
i18n,
};
</script>
<template>
<div>
<gl-table
:items="statusChecks"
:fields="$options.fields"
primary-key="id"
:empty-text="$options.i18n.emptyTableText"
show-empty
stacked="md"
>
<template #cell(protectedBranches)="{ item }">
<branch :branches="item.protectedBranches" />
</template>
<template #cell(actions)>
<actions />
</template>
</gl-table>
<gl-button category="secondary" variant="confirm" size="small">
{{ $options.i18n.addButton }}
</gl-button>
</div>
</template>
import Vue from 'vue';
import Vuex from 'vuex';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import StatusChecks from './components/status_checks.vue';
import createStore from './store';
Vue.use(Vuex);
export default function mountProjectSettingsApprovals(el) {
if (!el) {
return null;
}
const store = createStore();
const { statusChecksPath } = el.dataset;
store.dispatch('fetchStatusChecks', { statusChecksPath }).catch((error) => {
createFlash({
message: s__('StatusCheck|An error occurred fetching the status checks.'),
captureError: true,
error,
});
});
return new Vue({
el,
store,
render(h) {
return h(StatusChecks);
},
});
}
import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import * as types from './mutation_types';
export const fetchStatusChecks = ({ commit }, { statusChecksPath }) => {
commit(types.SET_LOADING, true);
return axios.get(statusChecksPath).then(({ data }) => {
commit(types.SET_STATUS_CHECKS, convertObjectPropsToCamelCase(data, { deep: true }));
commit(types.SET_LOADING, false);
});
};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import mutations from './mutations';
import createState from './state';
Vue.use(Vuex);
export default () =>
new Vuex.Store({
actions,
mutations,
state: createState(),
});
export const SET_LOADING = 'SET_LOADING';
export const SET_STATUS_CHECKS = 'SET_STATUS_CHECKS';
import * as types from './mutation_types';
export default {
[types.SET_LOADING](state, isLoading) {
state.isLoading = isLoading;
},
[types.SET_STATUS_CHECKS](state, statusChecks) {
state.statusChecks = statusChecks;
},
};
export default () => ({
isLoading: false,
statusChecks: [],
});
......@@ -118,6 +118,14 @@ module EE
{ data: data }
end
def status_checks_app_data(project)
{
data: {
status_checks_path: expose_path(api_v4_projects_external_approval_rules_path(id: project.id))
}
}
end
def can_modify_approvers(project = @project)
can?(current_user, :modify_approvers_rules, project)
end
......
......@@ -8,6 +8,9 @@
= render_ce 'projects/merge_request_merge_checks_settings', project: @project, form: form
- if ::Feature.enabled?(:ff_compliance_approval_gates, @project, default_enabled: :yaml)
= render_if_exists 'projects/merge_request_status_checks_settings'
= render 'projects/merge_request_merge_suggestions_settings', project: @project, form: form
- if @project.forked?
......
.form-group
%b= s_('StatusCheck|Status checks')
%p.text-secondary
// Update the documentation link once https://gitlab.com/gitlab-org/gitlab/-/issues/329517 is complete
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: '' }
= s_('StatusCheck|Check for a status response in Merge Requests. Failures do not block merges. %{link_start}Learn more%{link_end}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
#js-status-checks-settings{ status_checks_app_data(@project) }
.text-center.gl-mt-3
= sprite_icon('spinner', size: 24, css_class: 'gl-spinner')
import { GlButton } from '@gitlab/ui';
import Actions from 'ee/status_checks/components/actions.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
describe('Status checks actions', () => {
let wrapper;
const createWrapper = () => {
wrapper = shallowMountExtended(Actions, {
stubs: {
GlButton,
},
});
};
afterEach(() => {
wrapper.destroy();
});
const findEditBtn = () => wrapper.findByTestId('edit-btn');
const findRemoveBtn = () => wrapper.findByTestId('remove-btn');
it('renders the edit button', () => {
createWrapper();
expect(findEditBtn().text()).toBe('Edit');
});
it('renders the remove button', () => {
createWrapper();
expect(findRemoveBtn().text()).toBe('Remove...');
});
});
import { shallowMount } from '@vue/test-utils';
import Branch from 'ee/status_checks/components/branch.vue';
describe('Status checks branch', () => {
let wrapper;
const createWrapper = (props = {}) => {
wrapper = shallowMount(Branch, {
propsData: props,
});
};
afterEach(() => {
wrapper.destroy();
});
const findBranch = () => wrapper.find('span');
it('renders "Any branch" if no branch is given', () => {
createWrapper();
expect(findBranch().text()).toBe('Any branch');
expect(findBranch().classes('monospace')).toBe(true);
});
it('renders the first branch name when branches are given', () => {
createWrapper({ branches: [{ name: 'Foo' }, { name: 'Bar' }] });
expect(findBranch().text()).toBe('Foo');
expect(findBranch().classes('monospace')).toBe(false);
});
});
import { GlButton, GlTable } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import Actions from 'ee/status_checks/components/actions.vue';
import Branch from 'ee/status_checks/components/branch.vue';
import StatusChecks, { i18n } from 'ee/status_checks/components/status_checks.vue';
import createStore from 'ee/status_checks/store';
import { SET_STATUS_CHECKS } from 'ee/status_checks/store/mutation_types';
Vue.use(Vuex);
describe('Status checks', () => {
let store;
let wrapper;
const createWrapper = (mountFn = mount) => {
store = createStore();
wrapper = mountFn(StatusChecks, { store });
};
afterEach(() => {
wrapper.destroy();
});
const findAddButton = () => wrapper.findComponent(GlButton);
const findTable = () => wrapper.findComponent(GlTable);
const findHeaders = () => findTable().find('thead').find('tr').findAll('th');
const findBranch = (trIdx) => wrapper.findAllComponents(Branch).at(trIdx);
const findActions = (trIdx) => wrapper.findAllComponents(Actions).at(trIdx);
const findCell = (trIdx, tdIdx) => {
return findTable().find('tbody').findAll('tr').at(trIdx).findAll('td').at(tdIdx);
};
describe('Initially', () => {
it('renders the table', () => {
createWrapper(shallowMount);
expect(findTable().exists()).toBe(true);
});
it('renders the empty state when no status checks exist', () => {
createWrapper();
expect(findCell(0, 0).text()).toBe(i18n.emptyTableText);
});
it('renders the add button', () => {
createWrapper(shallowMount);
expect(findAddButton().text()).toBe(i18n.addButton);
});
});
describe('Filled table', () => {
const statusChecks = [
{ name: 'Foo', externalUrl: 'http://foo.com/api', protectedBranches: [] },
{ name: 'Bar', externalUrl: 'http://bar.com/api', protectedBranches: [{ name: 'main' }] },
];
beforeEach(() => {
createWrapper();
store.commit(SET_STATUS_CHECKS, statusChecks);
});
it('renders the headers', () => {
expect(findHeaders()).toHaveLength(4);
expect(findHeaders().at(0).text()).toBe(i18n.nameHeader);
expect(findHeaders().at(1).text()).toBe(i18n.apiHeader);
expect(findHeaders().at(2).text()).toBe(i18n.branchHeader);
expect(findHeaders().at(3).text()).toBe('');
});
describe.each(statusChecks)('status check %#', (statusCheck) => {
const index = statusChecks.indexOf(statusCheck);
it('renders the name', () => {
expect(findCell(index, 0).text()).toBe(statusCheck.name);
});
it('renders the URL', () => {
expect(findCell(index, 1).text()).toBe(statusCheck.externalUrl);
});
it('renders the branch', () => {
expect(findBranch(index, 1).props('branches')).toBe(statusCheck.protectedBranches);
});
it('renders the actions', () => {
expect(findActions(index, 1).exists()).toBe(true);
});
});
});
});
import { createWrapper } from '@vue/test-utils';
import mountStatusChecks from 'ee/status_checks/mount';
import createStore from 'ee/status_checks/store';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
jest.mock('ee/status_checks/store');
jest.mock('~/flash');
describe('mountStatusChecks', () => {
const statusChecksPath = '/api/v4/projects/1/external_approval_rules';
const dispatch = jest.fn();
let el;
const setUpDocument = () => {
el = document.createElement('div');
el.setAttribute('data-status-checks-path', statusChecksPath);
document.body.appendChild(el);
return el;
};
beforeEach(() => {
createStore.mockReturnValue({ dispatch, state: { statusChecks: [] } });
setUpDocument();
});
afterEach(() => {
el.remove();
el = null;
});
it('returns null if no element is given', () => {
expect(mountStatusChecks()).toBeNull();
});
it('returns the Vue component', async () => {
dispatch.mockResolvedValue({});
const wrapper = createWrapper(mountStatusChecks(el));
expect(dispatch).toHaveBeenCalledWith('fetchStatusChecks', { statusChecksPath });
expect(wrapper.exists()).toBe(true);
});
it('returns the Vue component with an error if fetchStatusChecks fails', async () => {
const error = new Error('Something went wrong');
dispatch.mockRejectedValueOnce(error);
const wrapper = createWrapper(mountStatusChecks(el));
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: 'An error occurred fetching the status checks.',
captureError: true,
error,
});
expect(wrapper.exists()).toBe(true);
});
});
import MockAdapter from 'axios-mock-adapter';
import * as actions from 'ee/status_checks/store/actions';
import * as types from 'ee/status_checks/store/mutation_types';
import axios from '~/lib/utils/axios_utils';
import httpStatusCodes from '~/lib/utils/http_status';
const statusChecksPath = '/api/v4/projects/1/external_approval_rules';
const commit = jest.fn();
let mockAxios;
describe('Status checks actions', () => {
beforeEach(() => {
mockAxios = new MockAdapter(axios);
});
afterEach(() => {
mockAxios.restore();
});
it(`should commit the API response`, async () => {
const data = [{ name: 'Foo' }, { name: 'Bar' }];
mockAxios.onGet(statusChecksPath).replyOnce(httpStatusCodes.OK, data);
await actions.fetchStatusChecks({ commit }, { statusChecksPath });
expect(commit).toHaveBeenCalledWith(types.SET_LOADING, true);
expect(commit).toHaveBeenCalledWith(types.SET_STATUS_CHECKS, data);
expect(commit).toHaveBeenCalledWith(types.SET_LOADING, false);
});
it('should error with a failed API response', async () => {
mockAxios.onGet(statusChecksPath).networkError();
await expect(actions.fetchStatusChecks({ commit }, { statusChecksPath })).rejects.toThrow(
new Error('Network Error'),
);
expect(commit).toHaveBeenCalledWith(types.SET_LOADING, true);
expect(commit).toHaveBeenCalledTimes(1);
});
});
import createStore from 'ee/status_checks/store';
describe('createStore', () => {
it('creates a new store', () => {
expect(createStore().state).toStrictEqual({
isLoading: false,
statusChecks: [],
});
});
});
import * as types from 'ee/status_checks/store/mutation_types';
import mutations from 'ee/status_checks/store/mutations';
import initialState from 'ee/status_checks/store/state';
describe('Status checks mutations', () => {
let state;
beforeEach(() => {
state = initialState();
});
describe(types.SET_LOADING, () => {
it('sets isLoading', () => {
expect(state.isLoading).toBe(false);
mutations[types.SET_LOADING](state, true);
expect(state.isLoading).toBe(true);
});
});
describe(types.SET_STATUS_CHECKS, () => {
it('sets the statusChecks', () => {
const statusChecks = [{ name: 'Foo' }, { name: 'Bar' }];
expect(state.statusChecks).toStrictEqual([]);
mutations[types.SET_STATUS_CHECKS](state, statusChecks);
expect(state.statusChecks).toStrictEqual(statusChecks);
});
});
});
import initialState from 'ee/status_checks/store/state';
describe('state', () => {
it('returns the expected default state', () => {
expect(initialState()).toStrictEqual({
isLoading: false,
statusChecks: [],
});
});
});
......@@ -378,4 +378,12 @@ RSpec.describe ProjectsHelper do
end
end
end
describe '#status_checks_app_data' do
subject { helper.status_checks_app_data(project) }
it 'returns the correct data' do
expect(subject[:data]).to eq({ status_checks_path: expose_path(api_v4_projects_external_approval_rules_path(id: project.id)) })
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'projects/_merge_request_status_checks_settings' do
let(:project) { build(:project) }
before do
assign(:project, project)
allow(view).to receive(:status_checks_app_data).and_return({ data: { status_checks_path: 'status-checks/path' } })
render partial: 'projects/merge_request_status_checks_settings'
end
it 'renders the settings title' do
expect(rendered).to have_content 'Status checks'
end
it 'renders the settings description', :aggregate_failures do
expect(rendered).to have_content 'Check for a status response in Merge Requests. Failures do not block merges.'
expect(rendered).to have_link 'Learn more', href: ''
end
it 'renders the settings app element', :aggregate_failures do
expect(rendered).to have_selector '#js-status-checks-settings'
expect(rendered).to have_selector "[data-status-checks-path='status-checks/path']"
end
it 'renders the loading spinner' do
expect(rendered).to have_selector '.gl-spinner'
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'projects/edit' do
let(:project) { create(:project) }
let(:user) { create(:admin) }
before do
assign(:project, project)
allow(controller).to receive(:current_user).and_return(user)
allow(view).to receive_messages(current_user: user,
can?: true,
current_application_settings: Gitlab::CurrentSettings.current_application_settings)
end
context 'status checks' do
context 'feature enabled' do
before do
stub_feature_flags(ff_compliance_approval_gates: true)
render
end
it 'shows the status checks area' do
expect(rendered).to have_content('Status check')
end
end
context 'feature disabled' do
before do
stub_feature_flags(ff_compliance_approval_gates: false)
render
end
it 'hides the status checks area' do
expect(rendered).not_to have_content('Status check')
end
end
end
end
......@@ -26984,6 +26984,9 @@ msgstr ""
msgid "Remove user from project"
msgstr ""
msgid "Remove..."
msgstr ""
msgid "Removed"
msgstr ""
......@@ -30641,15 +30644,33 @@ msgstr ""
msgid "StatusCheck|API to check"
msgstr ""
msgid "StatusCheck|Add status check"
msgstr ""
msgid "StatusCheck|An error occurred fetching the status checks."
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|Invoke an external API as part of the approvals"
msgstr ""
msgid "StatusCheck|No status checks are defined yet."
msgstr ""
msgid "StatusCheck|Remove status check"
msgstr ""
msgid "StatusCheck|Remove status check?"
msgstr ""
msgid "StatusCheck|Service name"
msgstr ""
msgid "StatusCheck|Status checks"
msgstr ""
msgid "StatusCheck|Status to check"
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