Commit 82fa4f9a authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '284794-jquery-dropdown-ci-template' into 'master'

Refactor jQuery dropdown implementation to GitLab UI GlDropdown in admin/application_settings/ci_cd/ci_template.js

See merge request gitlab-org/gitlab!63268
parents 23cdf953 a1c147b8
import $ from 'jquery';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { __ } from '~/locale';
export default class CiTemplate {
constructor() {
this.$input = $('#required_instance_ci_template_name');
this.$dropdown = $('.js-ci-template-dropdown');
this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text');
this.initDropdown();
}
initDropdown() {
initDeprecatedJQueryDropdown(this.$dropdown, {
data: this.formatDropdownList(),
selectable: true,
filterable: true,
allowClear: true,
toggleLabel: (item) => item.name,
search: {
fields: ['name'],
},
clicked: (clickEvent) => this.updateInputValue(clickEvent),
text: (item) => item.name,
});
this.setDropdownToggle();
}
formatDropdownList() {
return {
Reset: [
{
name: __('No required pipeline'),
id: null,
},
{
type: 'divider',
},
],
...this.$dropdown.data('data'),
};
}
setDropdownToggle() {
const initialValue = this.$input.val();
if (initialValue) {
this.$dropdownToggle.text(initialValue);
}
}
updateInputValue({ selectedObj, e }) {
e.preventDefault();
this.$input.val(selectedObj.id);
}
}
<script>
import {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlDropdownSectionHeader,
GlSearchBoxByType,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import { filterGitlabCiYmls } from './helpers';
export default {
name: 'CiTemplateDropdown',
components: {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlDropdownSectionHeader,
GlSearchBoxByType,
},
inject: {
initialSelectedGitlabCiYmlName: {
default: null,
},
gitlabCiYmls: {
default: {},
},
},
data() {
return {
selectedGitlabCiYmlName: this.initialSelectedGitlabCiYmlName,
searchTerm: '',
};
},
computed: {
filteredYmls() {
if (!this.searchTerm) {
return this.gitlabCiYmls;
}
return filterGitlabCiYmls(this.gitlabCiYmls, this.searchTerm);
},
filteredTemplateCategories() {
return Object.keys(this.filteredYmls);
},
dropdownText() {
return this.selectedGitlabCiYmlName || this.$options.i18n.defaultDropdownText;
},
selectedGitlabCiYmlValue() {
return this.selectedGitlabCiYmlName;
},
},
methods: {
isDropdownItemChecked(gitlabCiYml) {
return this.selectedGitlabCiYmlName === gitlabCiYml.name;
},
onDropdownItemClick(gitlabCiYml) {
if (this.selectedGitlabCiYmlName === gitlabCiYml.name) {
this.selectedGitlabCiYmlName = null;
} else {
this.selectedGitlabCiYmlName = gitlabCiYml.name;
}
},
},
i18n: {
defaultDropdownHeaderText: s__('AdminSettings|Select a CI/CD template'),
defaultDropdownText: s__('AdminSettings|No required pipeline'),
},
TYPING_DELAY: 100, // offset user's typing slightly to potentially save excessive DOM updates
};
</script>
<template>
<div>
<input
id="required_instance_ci_template_name"
type="hidden"
name="application_setting[required_instance_ci_template]"
:value="selectedGitlabCiYmlValue"
/>
<gl-dropdown
:text="dropdownText"
:header-text="$options.i18n.defaultDropdownHeaderText"
no-flip
class="gl-display-block gl-m-0"
>
<template #header>
<gl-search-box-by-type v-model.trim="searchTerm" :debounce="$options.TYPING_DELAY" />
</template>
<div v-for="categoryName in filteredTemplateCategories" :key="categoryName">
<gl-dropdown-divider />
<gl-dropdown-section-header>
{{ categoryName }}
</gl-dropdown-section-header>
<gl-dropdown-item
v-for="gitlabCiYml in filteredYmls[categoryName]"
:key="gitlabCiYml.id"
is-check-item
:is-checked="isDropdownItemChecked(gitlabCiYml)"
@click="onDropdownItemClick(gitlabCiYml)"
>
{{ gitlabCiYml.name }}
</gl-dropdown-item>
</div>
</gl-dropdown>
</div>
</template>
/**
* Filters [gitlabCiYmls] based on a given [searchTerm].
* Yml catagories with no items after filtering are not included in the returned object.
* @param {Object} gitlabCiYmls - { <categoryName>: [{ name, id }] }
* @param {String} searchTerm
* @returns {Object}
*/
export function filterGitlabCiYmls(gitlabCiYmls, searchTerm) {
return Object.keys(gitlabCiYmls).reduce((filteredYmls, category) => {
const categoryYmls = gitlabCiYmls[category].filter((yml) =>
yml.name.toLowerCase().startsWith(searchTerm),
);
if (categoryYmls.length > 0) {
Object.assign(filteredYmls, {
[category]: categoryYmls,
});
}
return filteredYmls;
}, {});
}
import CiTemplate from './ci_template';
import Vue from 'vue';
import CiTemplateDropdown from './ci_template_dropdown.vue';
const el = document.querySelector('.js-ci-template-dropdown');
const { gitlabCiYmls, value } = el.dataset;
// eslint-disable-next-line no-new
new CiTemplate();
new Vue({
el,
provide: {
gitlabCiYmls: JSON.parse(gitlabCiYmls),
initialSelectedGitlabCiYmlName: value,
},
render(createElement) {
return createElement(CiTemplateDropdown);
},
});
......@@ -16,10 +16,8 @@
= form_for @application_setting, url: ci_cd_admin_application_settings_path(anchor: 'js-required-pipeline-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
.form-group
= f.label :required_instance_ci_template, s_('AdminSettings|Select a CI/CD template'), class: 'text-muted'
= dropdown_tag(s_('AdminSettings|No required pipeline'), options: { toggle_class: 'js-ci-template-dropdown dropdown-select', title: s_('AdminSettings|Select a CI/CD template'), filter: true, placeholder: _("Filter"), data: { data: gitlab_ci_ymls(nil) } } )
= f.text_field :required_instance_ci_template, value: @application_setting.required_instance_ci_template, id: 'required_instance_ci_template_name', class: 'hidden'
.form-group.col-md-9.gl-p-0
= f.label :required_instance_ci_template, s_('AdminSettings|Select a CI/CD template')
.js-ci-template-dropdown{ data: { gitlab_ci_ymls: gitlab_ci_ymls(@project).to_json, value: @application_setting.required_instance_ci_template } }
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import CiTemplateDropdown from 'ee/pages/admin/application_settings/ci_cd/ci_template_dropdown.vue';
import { MOCK_CI_YMLS } from './mock_data';
describe('CiTemplateDropdown', () => {
let wrapper;
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findFirstDropdownItem = () => findDropdownItems().at(0);
const findDropdownHeaders = () => wrapper.findAllComponents(GlDropdownSectionHeader);
const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
const search = (searchTerm) => findSearchBox().vm.$emit('input', searchTerm);
const createComponent = ({ mountFn = shallowMount, provide } = {}) => {
wrapper = mountFn(CiTemplateDropdown, {
provide: { gitlabCiYmls: MOCK_CI_YMLS, ...provide },
});
};
const assertDefaultDropdownItems = () => {
const allYmls = Object.keys(MOCK_CI_YMLS).reduce((ymls, key) => {
MOCK_CI_YMLS[key].forEach((yml) => ymls.push(yml));
return ymls;
}, []);
expect(findDropdownItems()).toHaveLength(allYmls.length);
expect(findDropdownItems().wrappers.map((h) => h.text())).toEqual(
allYmls.map((yml) => yml.name),
);
};
const assetDefaultDropdownHeaders = () => {
expect(findDropdownHeaders()).toHaveLength(Object.keys(MOCK_CI_YMLS).length);
expect(findDropdownHeaders().wrappers.map((h) => h.text())).toEqual(Object.keys(MOCK_CI_YMLS));
};
afterEach(() => {
wrapper.destroy();
});
describe('renders', () => {
beforeEach(() => {
createComponent();
});
it('dropdown', () => {
expect(findDropdown().exists()).toBe(true);
});
it('dropdown items', () => {
assertDefaultDropdownItems();
});
it('dropdown section headers', () => {
assetDefaultDropdownHeaders();
});
it('dropown `text` prop with default text', () => {
expect(findDropdown().props('text')).toBe('No required pipeline');
});
});
describe('when providing `initialSelectedGitlabCiYmlName` data', () => {
it('sets respective dropdown item `isChecked` prop', () => {
createComponent({ provide: { initialSelectedGitlabCiYmlName: 'test' } });
const dropdownItem = findFirstDropdownItem();
expect(dropdownItem.props('isChecked')).toBe(true);
});
});
describe('when searching', () => {
beforeEach(async () => {
createComponent({ mountFn: mount });
await search('te');
});
it('renders filtered dropdown items', () => {
const dropdownItems = findDropdownItems();
expect(dropdownItems).toHaveLength(1);
expect(dropdownItems.at(0).text()).toBe('test');
});
it('only renders section headers for sections with items', () => {
expect(findDropdownHeaders().wrappers.map((h) => h.text())).toEqual(['General']);
});
describe('when search is cleared', () => {
it('resets template to default state', async () => {
await search('');
assertDefaultDropdownItems();
assetDefaultDropdownHeaders();
});
});
});
describe('when dropdown item is clicked', () => {
beforeEach(async () => {
createComponent();
const dropdownItem = findFirstDropdownItem();
await dropdownItem.vm.$emit('click');
});
it('sets dropdown item `isChecked` prop', () => {
const dropdownItem = findFirstDropdownItem();
expect(dropdownItem.props('isChecked')).toBe(true);
});
it('`isChecked` prop of other dropdown items remains unset', () => {
const dropdownItems = findDropdownItems().wrappers.slice(1);
expect(dropdownItems.some((item) => item.props('isChecked') === true)).toBe(false);
});
it('sets dropdown `text` prop to item name', () => {
expect(findDropdown().props('text')).toBe('test');
});
describe('when the selected dropdown item is clicked again', () => {
it("unsets item's `isChecked` prop", async () => {
const dropdownItem = findFirstDropdownItem();
await dropdownItem.vm.$emit('click');
expect(dropdownItem.props('isChecked')).toBe(false);
});
});
});
});
import CiTemplate from 'ee/pages/admin/application_settings/ci_cd/ci_template';
import { setHTMLFixture } from 'helpers/fixtures';
const DROPDOWN_DATA = {
Instance: [{ name: 'test', id: 'test' }],
General: [{ name: 'Android', id: 'Android' }],
};
const INITIAL_VALUE = 'Android';
describe('CI Template Dropdown (ee/pages/admin/application_settings/ci_cd/ci_template.js', () => {
let CiTemplateInstance;
beforeEach(() => {
setHTMLFixture(`
<div>
<button class="js-ci-template-dropdown" data-data=${JSON.stringify(DROPDOWN_DATA)}>
<span class="dropdown-toggle-text"></span>
</button>
<input id="required_instance_ci_template_name" value="${INITIAL_VALUE}" />
</div>
`);
CiTemplateInstance = new CiTemplate();
});
describe('Init Dropdown', () => {
it('Instantiates dropdown objects', () => {
expect(CiTemplateInstance.$input).toHaveLength(1);
expect(CiTemplateInstance.$dropdown).toHaveLength(1);
expect(CiTemplateInstance.$dropdownToggle).toHaveLength(1);
});
it('Sets the dropdown text value', () => {
expect(CiTemplateInstance.$dropdown.text().trim()).toBe(INITIAL_VALUE);
});
});
describe('Format dropdown list', () => {
it('Adds a reset option and divider', () => {
const expected = {
Reset: [{ name: 'No required pipeline', id: null }, { type: 'divider' }],
...DROPDOWN_DATA,
};
const actual = CiTemplateInstance.formatDropdownList();
expect(JSON.stringify(actual)).toBe(JSON.stringify(expected));
});
});
describe('Update input value', () => {
it('changes the value of the input', () => {
const selectedObj = { name: 'update', id: 'update' };
const e = { preventDefault: () => {} };
CiTemplateInstance.updateInputValue({ selectedObj, e });
expect(CiTemplateInstance.$input.val()).toBe('update');
});
});
});
import { filterGitlabCiYmls } from 'ee/pages/admin/application_settings/ci_cd/helpers';
describe('CI/CD helpers', () => {
const Yml = (name) => ({ name, id: name });
it.each`
gitlabCiYmls | searchTerm | result
${{ CatA: [Yml('test'), Yml('node')], CatB: [Yml('test')] }} | ${'t'} | ${{ CatA: [Yml('test')], CatB: [Yml('test')] }}
${{ CatA: [Yml('test'), Yml('tether')], CatB: [Yml('test')] }} | ${'tet'} | ${{ CatA: [Yml('tether')] }}
${{ CatA: [Yml('test'), Yml('node')], CatB: [Yml('test')] }} | ${'n'} | ${{ CatA: [Yml('node')] }}
${{ CatA: [Yml('test'), Yml('node')], CatB: [Yml('test')] }} | ${'asd'} | ${{}}
`(
'returns filtered list with correct categories when search term is $searchTerm',
({ gitlabCiYmls, searchTerm, result }) => {
expect(filterGitlabCiYmls(gitlabCiYmls, searchTerm)).toEqual(result);
},
);
});
export const MOCK_CI_YMLS = {
General: [
{
name: 'test',
id: 'test',
},
{
name: 'node',
id: 'node',
},
{
name: 'ruby',
id: 'ruby',
},
],
Security: [
{
name: 'fizz',
id: 'fizz',
},
{
name: 'buzz',
id: 'buzz',
},
{
name: 'bar',
id: 'bar',
},
],
};
......@@ -22111,9 +22111,6 @@ msgstr ""
msgid "No repository"
msgstr ""
msgid "No required pipeline"
msgstr ""
msgid "No runner executable"
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