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 // 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 @@ ...@@ -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_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) = form_errors(@application_setting)
%fieldset .form-group.col-md-9.gl-p-0
.form-group = f.label :required_instance_ci_template, s_('AdminSettings|Select a CI/CD template')
= f.label :required_instance_ci_template, s_('AdminSettings|Select a CI/CD template'), class: 'text-muted' .js-ci-template-dropdown{ data: { gitlab_ci_ymls: gitlab_ci_ymls(@project).to_json, value: @application_setting.required_instance_ci_template } }
= 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'
= f.submit _('Save changes'), class: "gl-button btn btn-confirm" = 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 "" ...@@ -22111,9 +22111,6 @@ msgstr ""
msgid "No repository" msgid "No repository"
msgstr "" msgstr ""
msgid "No required pipeline"
msgstr ""
msgid "No runner executable" msgid "No runner executable"
msgstr "" 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