Commit 2f848944 authored by Justin Ho Tuan Duong's avatar Justin Ho Tuan Duong Committed by Bob Van Landuyt

Add frontend for instance-level integration overrides

parent 100a2dd9
import axios from '~/lib/utils/axios_utils';
export const fetchOverrides = (overridesPath, { page, perPage }) => {
return axios.get(overridesPath, {
params: {
page,
per_page: perPage,
},
});
};
<script>
import { GlLink, GlLoadingIcon, GlPagination, GlTable } from '@gitlab/ui';
import { DEFAULT_PER_PAGE } from '~/api';
import createFlash from '~/flash';
import { fetchOverrides } from '~/integrations/overrides/api';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { truncateNamespace } from '~/lib/utils/text_utility';
import { __, s__ } from '~/locale';
import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
export default {
name: 'IntegrationOverrides',
components: {
GlLink,
GlLoadingIcon,
GlPagination,
GlTable,
ProjectAvatar,
},
props: {
overridesPath: {
type: String,
required: true,
},
},
fields: [
{
key: 'name',
label: __('Project'),
},
],
data() {
return {
isLoading: true,
overrides: [],
page: 1,
totalItems: 0,
};
},
computed: {
showPagination() {
return this.totalItems > this.$options.DEFAULT_PER_PAGE && this.overrides.length > 0;
},
},
mounted() {
this.loadOverrides();
},
methods: {
loadOverrides(page = this.page) {
this.isLoading = true;
fetchOverrides(this.overridesPath, {
page,
perPage: this.$options.DEFAULT_PER_PAGE,
})
.then(({ data, headers }) => {
const { page: newPage, total } = parseIntPagination(normalizeHeaders(headers));
this.page = newPage;
this.totalItems = total;
this.overrides = data;
})
.catch((error) => {
createFlash({
message: this.$options.i18n.defaultErrorMessage,
error,
captureError: true,
});
})
.finally(() => {
this.isLoading = false;
});
},
truncateNamespace,
},
DEFAULT_PER_PAGE,
i18n: {
defaultErrorMessage: s__(
'Integrations|An error occurred while loading projects using custom settings.',
),
tableEmptyText: s__('Integrations|There are no projects using custom settings'),
},
};
</script>
<template>
<div></div>
<div>
<gl-table
:items="overrides"
:fields="$options.fields"
:busy="isLoading"
show-empty
:empty-text="$options.i18n.tableEmptyText"
>
<template #cell(name)="{ item }">
<gl-link
class="gl-display-inline-flex gl-align-items-center gl-hover-text-decoration-none gl-text-body!"
:href="item.full_path"
>
<project-avatar
class="gl-mr-3"
:project-avatar-url="item.avatar_url"
:project-name="item.name"
aria-hidden="true"
/>
{{ truncateNamespace(item.full_name) }} /&nbsp;
<strong>{{ item.name }}</strong>
</gl-link>
</template>
<template #table-busy>
<gl-loading-icon size="md" class="gl-my-2" />
</template>
</gl-table>
<div class="gl-display-flex gl-justify-content-center gl-mt-5">
<gl-pagination
v-if="showPagination"
:per-page="$options.DEFAULT_PER_PAGE"
:total-items="totalItems"
:value="page"
:disabled="isLoading"
@input="loadOverrides"
/>
</div>
</div>
</template>
......@@ -26,8 +26,4 @@ class Admin::IntegrationsController < Admin::ApplicationController
def find_or_initialize_non_project_specific_integration(name)
Integration.find_or_initialize_non_project_specific_integration(name, instance: true)
end
def instance_level_integration_overrides?
Feature.enabled?(:instance_level_integration_overrides, default_enabled: :yaml)
end
end
......@@ -125,6 +125,17 @@ module IntegrationsHelper
!Gitlab.com?
end
def integration_tabs(integration:)
[
{ key: 'edit', text: _('Settings'), href: scoped_edit_integration_path(integration) },
({ key: 'overrides', text: s_('Integrations|Projects using custom settings'), href: scoped_overrides_integration_path(integration) } if instance_level_integration_overrides?)
].compact
end
def instance_level_integration_overrides?
Feature.enabled?(:instance_level_integration_overrides, default_enabled: :yaml)
end
def jira_issue_breadcrumb_link(issue_reference)
link_to '', { class: 'gl-display-flex gl-align-items-center gl-white-space-nowrap' } do
icon = image_tag image_path('illustrations/logos/jira.svg'), width: 15, height: 15, class: 'gl-mr-2'
......
- integration = local_assigns.fetch(:integration)
%h3.page-title
= integration.title
= form_for integration, as: :service, url: scoped_integration_path(integration), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'test-url' => scoped_test_integration_path(integration) } } do |form|
= render 'shared/service_settings', form: form, integration: integration
.tabs.gl-tabs
- active_tab = local_assigns.fetch(:active_tab, 'edit')
- active_classes = 'gl-tab-nav-item-active gl-tab-nav-item-active-indigo active'
- tabs = integration_tabs(integration: integration)
- if tabs.length <= 1
= yield
- else
.tabs.gl-tabs
%div
%ul.nav.gl-tabs-nav{ role: 'tablist' }
- tabs.each do |tab|
%li.nav-item{ role: 'presentation' }
%a.nav-link.gl-tab-nav-item{ role: 'tab', href: scoped_edit_integration_path(integration) }
= _('Settings')
%li.nav-item{ role: 'presentation' }
%a.nav-link.gl-tab-nav-item.gl-tab-nav-item-active.gl-tab-nav-item-active-indigo.active{ role: 'tab', href: scoped_overrides_integration_path(integration) }
= s_('Integrations|Projects using custom settings')
%a.nav-link.gl-tab-nav-item{ role: 'tab', class: (active_classes if tab[:key] == active_tab), href: tab[:href] }
= tab[:text]
.tab-content.gl-tab-content
.tab-pane.active{ role: 'tabpanel' }
.tab-pane.gl-pt-3.active{ role: 'tabpanel' }
= yield
......@@ -3,4 +3,8 @@
- page_title @integration.title, _('Integrations')
- @content_class = 'limit-container-width' unless fluid_layout
= render 'shared/integrations/form', integration: @integration
%h3.page-title
= @integration.title
= render 'shared/integrations/tabs', integration: @integration, active_tab: 'edit' do
= render 'shared/integrations/form', integration: @integration
......@@ -6,5 +6,5 @@
%h3.page-title
= @integration.title
= render 'shared/integrations/tabs', integration: @integration do
= render 'shared/integrations/tabs', integration: @integration, active_tab: 'overrides' do
.js-vue-integration-overrides{ data: integration_overrides_data(@integration) }
......@@ -17912,6 +17912,9 @@ msgstr ""
msgid "Integrations|All projects inheriting these settings will also be reset."
msgstr ""
msgid "Integrations|An error occurred while loading projects using custom settings."
msgstr ""
msgid "Integrations|Browser limitations"
msgstr ""
......@@ -18032,6 +18035,9 @@ msgstr ""
msgid "Integrations|Standard"
msgstr ""
msgid "Integrations|There are no projects using custom settings"
msgstr ""
msgid "Integrations|This integration, and inheriting projects were reset."
msgstr ""
......
......@@ -11,6 +11,24 @@ RSpec.describe 'User activates the instance-level Mattermost Slash Command integ
end
let(:edit_path) { edit_admin_application_settings_integration_path(:mattermost_slash_commands) }
let(:overrides_path) { overrides_admin_application_settings_integration_path(:mattermost_slash_commands) }
include_examples 'user activates the Mattermost Slash Command integration'
it 'displays navigation tabs' do
expect(page).to have_link('Settings', href: edit_path)
expect(page).to have_link('Projects using custom settings', href: overrides_path)
end
context 'when instance_level_integration_overrides is disabled' do
before do
stub_feature_flags(instance_level_integration_overrides: false)
visit_instance_integration('Mattermost slash commands')
end
it 'does not display the overrides tab' do
expect(page).not_to have_link('Settings', href: edit_path)
expect(page).not_to have_link('Projects using custom settings', href: overrides_path)
end
end
end
import { GlTable, GlLink, GlPagination } from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import { DEFAULT_PER_PAGE } from '~/api';
import createFlash from '~/flash';
import IntegrationOverrides from '~/integrations/overrides/components/integration_overrides.vue';
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
jest.mock('~/flash');
const mockOverrides = Array(DEFAULT_PER_PAGE * 3)
.fill(1)
.map((_, index) => ({
name: `test-proj-${index}`,
avatar_url: `avatar-${index}`,
full_path: `test-proj-${index}`,
full_name: `test-proj-${index}`,
}));
describe('IntegrationOverrides', () => {
let wrapper;
let mockAxios;
const defaultProps = {
overridesPath: 'mock/overrides',
};
const createComponent = ({ mountFn = shallowMount } = {}) => {
wrapper = mountFn(IntegrationOverrides, {
propsData: defaultProps,
});
};
beforeEach(() => {
mockAxios = new MockAdapter(axios);
mockAxios.onGet(defaultProps.overridesPath).reply(httpStatus.OK, mockOverrides, {
'X-TOTAL': mockOverrides.length,
'X-PAGE': 1,
});
});
afterEach(() => {
mockAxios.restore();
wrapper.destroy();
});
const findGlTable = () => wrapper.findComponent(GlTable);
const findPagination = () => wrapper.findComponent(GlPagination);
const findRowsAsModel = () =>
findGlTable()
.findAllComponents(GlLink)
.wrappers.map((link) => {
const avatar = link.findComponent(ProjectAvatar);
return {
href: link.attributes('href'),
avatarUrl: avatar.props('projectAvatarUrl'),
avatarName: avatar.props('projectName'),
text: link.text(),
};
});
describe('while loading', () => {
it('sets GlTable `busy` attribute to `true`', () => {
createComponent();
const table = findGlTable();
expect(table.exists()).toBe(true);
expect(table.attributes('busy')).toBe('true');
});
});
describe('when initial request is successful', () => {
it('sets GlTable `busy` attribute to `false`', async () => {
createComponent();
await waitForPromises();
const table = findGlTable();
expect(table.exists()).toBe(true);
expect(table.attributes('busy')).toBeFalsy();
});
describe('table template', () => {
beforeEach(async () => {
createComponent({ mountFn: mount });
await waitForPromises();
});
it('renders overrides as rows in table', () => {
expect(findRowsAsModel()).toEqual(
mockOverrides.map((x) => ({
href: x.full_path,
avatarUrl: x.avatar_url,
avatarName: x.name,
text: expect.stringContaining(x.full_name),
})),
);
});
});
});
describe('when request fails', () => {
beforeEach(async () => {
mockAxios.onGet(defaultProps.overridesPath).reply(httpStatus.INTERNAL_SERVER_ERROR);
createComponent();
await waitForPromises();
});
it('calls createFlash', () => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith({
message: IntegrationOverrides.i18n.defaultErrorMessage,
captureError: true,
error: expect.any(Error),
});
});
});
describe('pagination', () => {
it('triggers fetch when `input` event is emitted', async () => {
createComponent();
jest.spyOn(axios, 'get');
await waitForPromises();
await findPagination().vm.$emit('input', 2);
expect(axios.get).toHaveBeenCalledWith(defaultProps.overridesPath, {
params: { page: 2, per_page: DEFAULT_PER_PAGE },
});
});
it('does not render with <=1 page', async () => {
mockAxios.onGet(defaultProps.overridesPath).reply(httpStatus.OK, [mockOverrides[0]], {
'X-TOTAL': 1,
'X-PAGE': 1,
});
createComponent();
await waitForPromises();
expect(findPagination().exists()).toBe(false);
});
});
});
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