Commit a2958f88 authored by Alex Kalderimis's avatar Alex Kalderimis

Merge branch '348737-haml-listbox-helper' into 'master'

Make starrers sort dropdown Pajamas compliant

See merge request gitlab-org/gitlab!78708
parents b805fc73 8fe885cb
import { initRedirectListboxBehavior } from '~/listbox/redirect_behavior';
initRedirectListboxBehavior();
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
export function parseAttributes(el) {
const { items: itemsString, selected, right: rightString } = el.dataset;
const items = JSON.parse(itemsString);
const right = parseBoolean(rightString);
const { className } = el;
return { items, selected, right, className };
}
export function initListbox(el, { onChange } = {}) {
if (!el) return null;
const { items, selected, right, className } = parseAttributes(el);
return new Vue({
el,
data() {
return {
selected,
};
},
computed: {
text() {
return items.find(({ value }) => value === this.selected)?.text;
},
},
render(h) {
return h(
GlDropdown,
{
props: {
text: this.text,
right,
},
class: className,
},
items.map((item) =>
h(
GlDropdownItem,
{
props: {
isCheckItem: true,
isChecked: this.selected === item.value,
},
on: {
click: () => {
this.selected = item.value;
if (typeof onChange === 'function') {
onChange(item);
}
},
},
},
item.text,
),
),
);
},
});
}
import { initListbox } from '~/listbox';
import { redirectTo } from '~/lib/utils/url_utility';
/**
* Instantiates GlListbox components with redirect behavior for tags created
* with the `gl_redirect_listbox_tag` HAML helper.
*
* NOTE: Do not import this script explicitly. Using `gl_redirect_listbox_tag`
* automatically injects the `redirect_listbox` bundle, which calls this
* function.
*/
export function initRedirectListboxBehavior() {
const elements = Array.from(document.querySelectorAll('.js-redirect-listbox'));
return elements.map((el) =>
initListbox(el, {
onChange({ href }) {
redirectTo(href);
},
}),
);
}
# frozen_string_literal: true
module ListboxHelper
DROPDOWN_CONTAINER_CLASSES = %w[dropdown b-dropdown gl-new-dropdown btn-group js-redirect-listbox].freeze
DROPDOWN_BUTTON_CLASSES = %w[btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle].freeze
DROPDOWN_INNER_CLASS = 'gl-new-dropdown-button-text'
DROPDOWN_ICON_CLASS = 'gl-button-icon dropdown-chevron gl-icon'
# Creates a listbox component with redirect behavior.
#
# Use this for migrating existing deprecated dropdowns to become
# Pajamas-compliant. New features should use Vue components directly instead.
#
# The `items` parameter must be an array of hashes, each with `value`, `text`
# and `href` keys, where `value` is a unique identifier for the item (e.g.,
# the sort key), `text` is the user-facing string for the item, and `href` is
# the path to redirect to when that item is selected.
#
# The `selected` parameter is the currently selected `value`, and must
# correspond to one of the `items`, or be `nil`. When `selected.nil?`, the first item is selected.
#
# The final parameter `html_options` applies arbitrary attributes to the
# returned tag. Some of these are passed to the underlying Vue component as
# props, e.g., to right-align the menu of items, add `data: { right: true }`.
#
# Examples:
# # Create a listbox with two items, with the first item selected
# - items = [{ value: 'foo', text: 'Name, ascending', href: '/foo' },
# { value: 'bar', text: 'Name, descending', href: '/bar' }]
# = gl_redirect_listbox_tag items, 'foo'
#
# # Create the same listbox, right-align the menu and add margin styling
# = gl_redirect_listbox_tag items, 'foo', class: 'gl-ml-3', data: { right: true }
def gl_redirect_listbox_tag(items, selected, html_options = {})
# Add script tag for app/assets/javascripts/entrypoints/behaviors/redirect_listbox.js
content_for :page_specific_javascripts do
webpack_bundle_tag 'redirect_listbox'
end
selected ||= items.first[:value]
selected_option = items.find { |opt| opt[:value] == selected }
raise ArgumentError, "cannot find #{selected} in #{items}" unless selected_option
button = button_tag(type: :button, class: DROPDOWN_BUTTON_CLASSES) do
content_tag(:span, selected_option[:text], class: DROPDOWN_INNER_CLASS) +
sprite_icon('chevron-down', css_class: DROPDOWN_ICON_CLASS)
end
classes = [*DROPDOWN_CONTAINER_CLASSES, *html_options[:class]]
data = html_options.fetch(:data, {}).merge(items: items, selected: selected)
content_tag(:div, button, html_options.merge({
class: classes,
data: data
}))
end
end
...@@ -12,21 +12,13 @@ ...@@ -12,21 +12,13 @@
= search_field_tag :search, params[:search], { placeholder: _('Search'), class: 'form-control', spellcheck: false } = search_field_tag :search, params[:search], { placeholder: _('Search'), class: 'form-control', spellcheck: false }
%button.user-search-btn{ type: "submit", "aria-label" => _("Submit search") } %button.user-search-btn{ type: "submit", "aria-label" => _("Submit search") }
= sprite_icon('search') = sprite_icon('search')
.dropdown.inline.gl-ml-3 - starrers_sort_options = starrers_sort_options_hash.map { |value, text| { value: value, text: text, href: filter_starrer_path(sort: value) } }
= dropdown_toggle(starrers_sort_options_hash[@sort], { toggle: 'dropdown' }) = gl_redirect_listbox_tag starrers_sort_options, @sort, class: 'gl-ml-3', data: { right: true }
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable
%li.dropdown-header
= _("Sort by")
- starrers_sort_options_hash.each do |value, title|
%li
= link_to filter_starrer_path(sort: value), class: ("is-active" if @sort == value) do
= title
- if @starrers.size > 0 - if @starrers.size > 0
.row.gl-mt-3 .row.gl-mt-3
= render partial: 'starrer', collection: @starrers, as: :starrer = render partial: 'starrer', collection: @starrers, as: :starrer
= paginate @starrers, theme: 'gitlab' = paginate @starrers, theme: 'gitlab'
- elsif params[:search].present?
.nothing-here-block= _('No starrers matched your search')
- else - else
- if params[:search].present? .nothing-here-block= _('Nobody has starred this repository yet')
.nothing-here-block= _('No starrers matched your search')
- else
.nothing-here-block= _('Nobody has starred this repository yet')
...@@ -143,6 +143,7 @@ function generateEntries() { ...@@ -143,6 +143,7 @@ function generateEntries() {
performance_bar: './performance_bar/index.js', performance_bar: './performance_bar/index.js',
jira_connect_app: './jira_connect/subscriptions/index.js', jira_connect_app: './jira_connect/subscriptions/index.js',
sandboxed_mermaid: './lib/mermaid.js', sandboxed_mermaid: './lib/mermaid.js',
redirect_listbox: './entrypoints/behaviors/redirect_listbox.js',
}; };
return Object.assign(manualEntries, incrementalCompiler.filterEntryPoints(autoEntries)); return Object.assign(manualEntries, incrementalCompiler.filterEntryPoints(autoEntries));
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'initRedirectListboxBehavior', '(JavaScript fixtures)', type: :helper do
include JavaScriptFixturesHelpers
include ListboxHelper
let(:response) { @tag }
it 'listbox/redirect_listbox.html' do
items = [{
value: 'foo',
text: 'Foo',
href: '/foo',
arbitrary_key: 'foo xyz'
}, {
value: 'bar',
text: 'Bar',
href: '/bar',
arbitrary_key: 'bar xyz'
}, {
value: 'qux',
text: 'Qux',
href: '/qux',
arbitrary_key: 'qux xyz'
}]
@tag = helper.gl_redirect_listbox_tag(items, 'bar', class: %w[test-class-1 test-class-2], data: { right: true })
end
end
import { nextTick } from 'vue';
import { getAllByRole, getByRole } from '@testing-library/dom';
import { GlDropdown } from '@gitlab/ui';
import { createWrapper } from '@vue/test-utils';
import { initListbox, parseAttributes } from '~/listbox';
import { getFixture, setHTMLFixture } from 'helpers/fixtures';
jest.mock('~/lib/utils/url_utility');
const fixture = getFixture('listbox/redirect_listbox.html');
const parsedAttributes = (() => {
const div = document.createElement('div');
div.innerHTML = fixture;
return parseAttributes(div.firstChild);
})();
describe('initListbox', () => {
let instance;
afterEach(() => {
if (instance) {
instance.$destroy();
}
});
const setup = (...args) => {
instance = initListbox(...args);
};
// TODO: Rewrite these finders to use better semantics once the
// implementation is switched to GlListbox
// https://gitlab.com/gitlab-org/gitlab/-/issues/348738
const findToggleButton = () => document.body.querySelector('.gl-dropdown-toggle');
const findItem = (text) => getByRole(document.body, 'menuitem', { name: text });
const findItems = () => getAllByRole(document.body, 'menuitem');
const findSelectedItems = () =>
findItems().filter(
(menuitem) =>
!menuitem
.querySelector('.gl-new-dropdown-item-check-icon')
.classList.contains('gl-visibility-hidden'),
);
it('returns null given no element', () => {
setup();
expect(instance).toBe(null);
});
it('throws given an invalid element', () => {
expect(() => setup(document.body)).toThrow();
});
describe('given a valid element', () => {
let onChangeSpy;
beforeEach(async () => {
setHTMLFixture(fixture);
onChangeSpy = jest.fn();
setup(document.querySelector('.js-redirect-listbox'), { onChange: onChangeSpy });
await nextTick();
});
it('returns an instance', () => {
expect(instance).not.toBe(null);
});
it('renders button with selected item text', () => {
expect(findToggleButton().textContent.trim()).toBe('Bar');
});
it('has the correct item selected', () => {
const selectedItems = findSelectedItems();
expect(selectedItems).toHaveLength(1);
expect(selectedItems[0].textContent.trim()).toBe('Bar');
});
it('applies additional classes from the original element', () => {
expect(instance.$el.classList).toContain('test-class-1', 'test-class-2');
});
describe.each(parsedAttributes.items)('clicking on an item', (item) => {
beforeEach(async () => {
findItem(item.text).click();
await nextTick();
});
it('calls the onChange callback with the item', () => {
expect(onChangeSpy).toHaveBeenCalledWith(item);
});
it('updates the toggle button text', () => {
expect(findToggleButton().textContent.trim()).toBe(item.text);
});
it('marks the item as selected', () => {
const selectedItems = findSelectedItems();
expect(selectedItems).toHaveLength(1);
expect(selectedItems[0].textContent.trim()).toBe(item.text);
});
});
it('passes the "right" prop through to the underlying component', () => {
const wrapper = createWrapper(instance).findComponent(GlDropdown);
expect(wrapper.props('right')).toBe(parsedAttributes.right);
});
});
});
import { initListbox } from '~/listbox';
import { initRedirectListboxBehavior } from '~/listbox/redirect_behavior';
import { redirectTo } from '~/lib/utils/url_utility';
import { getFixture, setHTMLFixture } from 'helpers/fixtures';
jest.mock('~/lib/utils/url_utility');
jest.mock('~/listbox', () => ({
initListbox: jest.fn().mockReturnValue({ foo: true }),
}));
const fixture = getFixture('listbox/redirect_listbox.html');
describe('initRedirectListboxBehavior', () => {
let instances;
beforeEach(() => {
setHTMLFixture(`
${fixture}
${fixture}
`);
instances = initRedirectListboxBehavior();
});
it('calls initListbox for each .js-redirect-listbox', () => {
expect(instances).toEqual([{ foo: true }, { foo: true }]);
expect(initListbox).toHaveBeenCalledTimes(2);
initListbox.mock.calls.forEach((callArgs, i) => {
const elements = document.querySelectorAll('.js-redirect-listbox');
expect(callArgs[0]).toBe(elements[i]);
expect(callArgs[1]).toEqual({
onChange: expect.any(Function),
});
});
});
it('passes onChange handler to initListbox that calls redirectTo', () => {
const [firstCallArgs] = initListbox.mock.calls;
const { onChange } = firstCallArgs[1];
const mockItem = { href: '/foo' };
expect(redirectTo).not.toHaveBeenCalled();
onChange(mockItem);
expect(redirectTo).toHaveBeenCalledWith(mockItem.href);
});
});
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ListboxHelper do
subject do
tag = helper.gl_redirect_listbox_tag(items, selected, html_options)
Nokogiri::HTML.fragment(tag).children.first
end
before do
allow(helper).to receive(:sprite_icon).with(
'chevron-down',
css_class: 'gl-button-icon dropdown-chevron gl-icon'
).and_return('<span class="icon"></span>'.html_safe)
end
let(:selected) { 'bar' }
let(:html_options) { {} }
let(:items) do
[
{ value: 'foo', text: 'Foo' },
{ value: 'bar', text: 'Bar' }
]
end
describe '#gl_redirect_listbox_tag' do
it 'creates root element with expected classes' do
expect(subject.classes).to include(*%w[
dropdown
b-dropdown
gl-new-dropdown
btn-group
js-redirect-listbox
])
end
it 'sets data attributes for items and selected' do
expect(subject.attributes['data-items'].value).to eq(items.to_json)
expect(subject.attributes['data-selected'].value).to eq(selected)
end
it 'adds styled button' do
expect(subject.at_css('button').classes).to include(*%w[
btn
dropdown-toggle
btn-default
btn-md
gl-button
gl-dropdown-toggle
])
end
it 'sets button text to selected item' do
expect(subject.at_css('button').content).to eq('Bar')
end
context 'given html_options' do
let(:html_options) { { class: 'test-class', data: { qux: 'qux' } } }
it 'applies them to the root element' do
expect(subject.attributes['data-qux'].value).to eq('qux')
expect(subject.classes).to include('test-class')
end
end
context 'when selected does not match any item' do
let(:selected) { 'qux' }
it 'raises an error' do
expect { subject }.to raise_error(ArgumentError, /cannot find qux/)
end
end
end
end
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