Commit 6fbd2c64 authored by Kushal Pandya's avatar Kushal Pandya

Add support for bulk editing sidebar

Adds support for bulk editing Issuables via
sidebar.
parent b16a1537
<script>
export default {
props: {
expanded: {
type: Boolean,
required: true,
},
},
watch: {
expanded(value) {
const layoutPageEl = document.querySelector('.layout-page');
if (layoutPageEl) {
layoutPageEl.classList.toggle('right-sidebar-expanded', value);
layoutPageEl.classList.toggle('right-sidebar-collapsed', !value);
}
},
},
};
</script>
<template>
<aside
:class="{ 'right-sidebar-expanded': expanded, 'right-sidebar-collapsed': !expanded }"
class="issues-bulk-update right-sidebar"
aria-live="polite"
>
<div
class="gl-display-flex gl-justify-content-space-between gl-p-4 gl-border-b-1 gl-border-b-solid gl-border-gray-100"
>
<slot name="bulk-edit-actions"></slot>
</div>
<slot name="sidebar-items"></slot>
</aside>
</template>
<script>
import { GlLink, GlIcon, GlLabel, GlTooltipDirective } from '@gitlab/ui';
import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
......@@ -14,6 +14,7 @@ export default {
GlLink,
GlIcon,
GlLabel,
GlFormCheckbox,
IssuableAssignees,
},
directives: {
......@@ -33,6 +34,15 @@ export default {
type: Boolean,
required: true,
},
showCheckbox: {
type: Boolean,
required: true,
},
checked: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
author() {
......@@ -109,8 +119,15 @@ export default {
</script>
<template>
<li class="issue px-3">
<li class="issue gl-px-5!">
<div class="issue-box">
<div v-if="showCheckbox" class="issue-check">
<gl-form-checkbox
class="gl-mr-0"
:checked="checked"
@input="$emit('checked-input', $event)"
/>
</div>
<div class="issuable-info-container">
<div class="issuable-main-info">
<div data-testid="issuable-title" class="issue-title title">
......
<script>
import { GlSkeletonLoading, GlPagination } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import IssuableTabs from './issuable_tabs.vue';
import IssuableItem from './issuable_item.vue';
import IssuableBulkEditSidebar from './issuable_bulk_edit_sidebar.vue';
import { DEFAULT_SKELETON_COUNT } from '../constants';
......@@ -15,6 +17,7 @@ export default {
IssuableTabs,
FilteredSearchBar,
IssuableItem,
IssuableBulkEditSidebar,
GlPagination,
},
props: {
......@@ -85,6 +88,11 @@ export default {
required: false,
default: false,
},
showBulkEditSidebar: {
type: Boolean,
required: false,
default: false,
},
defaultPageSize: {
type: Number,
required: false,
......@@ -116,6 +124,11 @@ export default {
default: true,
},
},
data() {
return {
checkedIssuables: {},
};
},
computed: {
skeletonItemCount() {
const { totalItems, defaultPageSize, currentPage } = this;
......@@ -128,8 +141,40 @@ export default {
}
return DEFAULT_SKELETON_COUNT;
},
allIssuablesChecked() {
return this.bulkEditIssuables.length === this.issuables.length;
},
/**
* Returns all the checked issuables from `checkedIssuables` map.
*/
bulkEditIssuables() {
return Object.keys(this.checkedIssuables).reduce((acc, issuableId) => {
if (this.checkedIssuables[issuableId].checked) {
acc.push(this.checkedIssuables[issuableId].issuable);
}
return acc;
}, []);
},
},
watch: {
issuables(list) {
this.checkedIssuables = list.reduce((acc, issuable) => {
const id = this.issuableId(issuable);
acc[id] = {
// By default, an issuable is not checked,
// But if `checkedIssuables` is already
// populated, use existing value.
checked:
typeof this.checkedIssuables[id] !== 'boolean'
? false
: this.checkedIssuables[id].checked,
// We're caching issuable reference here
// for ease of populating in `bulkEditIssuables`.
issuable,
};
return acc;
}, {});
},
urlParams: {
deep: true,
immediate: true,
......@@ -144,6 +189,22 @@ export default {
},
},
},
methods: {
issuableId(issuable) {
return issuable.id || issuable.iid || uniqueId();
},
issuableChecked(issuable) {
return this.checkedIssuables[this.issuableId(issuable)]?.checked;
},
handleIssuableCheckedInput(issuable, value) {
this.checkedIssuables[this.issuableId(issuable)].checked = value;
},
handleAllIssuablesCheckedInput(value) {
Object.keys(this.checkedIssuables).forEach(issuableId => {
this.checkedIssuables[issuableId].checked = value;
});
},
},
};
</script>
......@@ -167,10 +228,21 @@ export default {
:sort-options="sortOptions"
:initial-filter-value="initialFilterValue"
:initial-sort-by="initialSortBy"
:show-checkbox="showBulkEditSidebar"
:checkbox-checked="allIssuablesChecked"
class="gl-flex-grow-1 row-content-block"
@checked-input="handleAllIssuablesCheckedInput"
@onFilter="$emit('filter', $event)"
@onSort="$emit('sort', $event)"
/>
<issuable-bulk-edit-sidebar :expanded="showBulkEditSidebar">
<template #bulk-edit-actions>
<slot name="bulk-edit-actions" :checked-issuables="bulkEditIssuables"></slot>
</template>
<template #sidebar-items>
<slot name="sidebar-items" :checked-issuables="bulkEditIssuables"></slot>
</template>
</issuable-bulk-edit-sidebar>
<div class="issuables-holder">
<ul v-if="issuablesLoading" class="content-list">
<li v-for="n in skeletonItemCount" :key="n" class="issue gl-px-5! gl-py-5!">
......@@ -183,10 +255,13 @@ export default {
>
<issuable-item
v-for="issuable in issuables"
:key="issuable.id"
:key="issuableId(issuable)"
:issuable-symbol="issuableSymbol"
:issuable="issuable"
:enable-label-permalinks="enableLabelPermalinks"
:show-checkbox="showBulkEditSidebar"
:checked="issuableChecked(issuable)"
@checked-input="handleIssuableCheckedInput(issuable, $event)"
>
<template #reference>
<slot name="reference" :issuable="issuable"></slot>
......
......@@ -5,6 +5,7 @@ import {
GlButton,
GlDropdown,
GlDropdownItem,
GlFormCheckbox,
GlTooltipDirective,
} from '@gitlab/ui';
......@@ -25,6 +26,7 @@ export default {
GlButton,
GlDropdown,
GlDropdownItem,
GlFormCheckbox,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -59,6 +61,16 @@ export default {
default: '',
validator: value => value === '' || /(_desc)|(_asc)/g.test(value),
},
showCheckbox: {
type: Boolean,
required: false,
default: false,
},
checkboxChecked: {
type: Boolean,
required: false,
default: false,
},
searchInputPlaceholder: {
type: String,
required: true,
......@@ -291,6 +303,12 @@ export default {
<template>
<div class="vue-filtered-search-bar-container d-md-flex">
<gl-form-checkbox
v-if="showCheckbox"
class="gl-align-self-center"
:checked="checkboxChecked"
@input="$emit('checked-input', $event)"
/>
<gl-filtered-search
ref="filteredSearchInput"
v-model="filterValue"
......
import { shallowMount } from '@vue/test-utils';
import IssuableBulkEditSidebar from '~/issuable_list/components/issuable_bulk_edit_sidebar.vue';
const createComponent = ({ expanded = true } = {}) =>
shallowMount(IssuableBulkEditSidebar, {
propsData: {
expanded,
},
slots: {
'bulk-edit-actions': `
<button class="js-edit-issuables">Edit issuables</button>
`,
'sidebar-items': `
<button class="js-sidebar-dropdown">Labels</button>
`,
},
});
describe('IssuableBulkEditSidebar', () => {
let wrapper;
beforeEach(() => {
setFixtures('<div class="layout-page right-sidebar-collapsed"></div>');
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('watch', () => {
describe('expanded', () => {
it.each`
expanded | layoutPageClass
${true} | ${'right-sidebar-expanded'}
${false} | ${'right-sidebar-collapsed'}
`(
'sets class "$layoutPageClass" on element `.layout-page` when expanded prop is $expanded',
async ({ expanded, layoutPageClass }) => {
const wrappeCustom = createComponent({
expanded: !expanded,
});
// We need to manually flip the value of `expanded` for
// watcher to trigger.
wrappeCustom.setProps({
expanded,
});
await wrappeCustom.vm.$nextTick();
expect(document.querySelector('.layout-page').classList.contains(layoutPageClass)).toBe(
true,
);
wrappeCustom.destroy();
},
);
});
});
describe('template', () => {
it.each`
expanded | layoutPageClass
${true} | ${'right-sidebar-expanded'}
${false} | ${'right-sidebar-collapsed'}
`(
'renders component container with class "$layoutPageClass" when expanded prop is $expanded',
async ({ expanded, layoutPageClass }) => {
const wrappeCustom = createComponent({
expanded: !expanded,
});
// We need to manually flip the value of `expanded` for
// watcher to trigger.
wrappeCustom.setProps({
expanded,
});
await wrappeCustom.vm.$nextTick();
expect(wrappeCustom.classes()).toContain(layoutPageClass);
wrappeCustom.destroy();
},
);
it('renders contents for slot `bulk-edit-actions`', () => {
expect(wrapper.find('button.js-edit-issuables').exists()).toBe(true);
});
it('renders contents for slot `sidebar-items`', () => {
expect(wrapper.find('button.js-sidebar-dropdown').exists()).toBe(true);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlLink, GlLabel } from '@gitlab/ui';
import { GlLink, GlLabel, GlFormCheckbox } from '@gitlab/ui';
import IssuableItem from '~/issuable_list/components/issuable_item.vue';
import IssuableAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
......@@ -12,6 +12,7 @@ const createComponent = ({ issuableSymbol = '#', issuable = mockIssuable, slots
issuableSymbol,
issuable,
enableLabelPermalinks: true,
showCheckbox: false,
},
slots,
});
......@@ -196,6 +197,25 @@ describe('IssuableItem', () => {
expect(titleEl.find(GlLink).text()).toBe(mockIssuable.title);
});
it('renders checkbox when `showCheckbox` prop is true', async () => {
wrapper.setProps({
showCheckbox: true,
});
await wrapper.vm.$nextTick();
expect(wrapper.find(GlFormCheckbox).exists()).toBe(true);
expect(wrapper.find(GlFormCheckbox).attributes('checked')).not.toBeDefined();
wrapper.setProps({
checked: true,
});
await wrapper.vm.$nextTick();
expect(wrapper.find(GlFormCheckbox).attributes('checked')).toBe('true');
});
it('renders issuable title with `target` set as "_blank" when issuable.webUrl is external', async () => {
wrapper.setProps({
issuable: {
......
......@@ -8,11 +8,14 @@ import IssuableTabs from '~/issuable_list/components/issuable_tabs.vue';
import IssuableItem from '~/issuable_list/components/issuable_item.vue';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import { mockIssuableListProps } from '../mock_data';
import { mockIssuableListProps, mockIssuables } from '../mock_data';
const createComponent = (propsData = mockIssuableListProps) =>
const createComponent = ({ props = mockIssuableListProps, data = {} } = {}) =>
mount(IssuableListRoot, {
propsData,
propsData: props,
data() {
return data;
},
slots: {
'nav-actions': `
<button class="js-new-issuable">New issuable</button>
......@@ -35,6 +38,14 @@ describe('IssuableListRoot', () => {
});
describe('computed', () => {
const mockCheckedIssuables = {
[mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] },
[mockIssuables[1].iid]: { checked: true, issuable: mockIssuables[1] },
[mockIssuables[2].iid]: { checked: true, issuable: mockIssuables[2] },
};
const mIssuables = [mockIssuables[0], mockIssuables[1], mockIssuables[2]];
describe('skeletonItemCount', () => {
it.each`
totalItems | defaultPageSize | currentPage | returnValue
......@@ -57,9 +68,62 @@ describe('IssuableListRoot', () => {
},
);
});
describe('allIssuablesChecked', () => {
it.each`
checkedIssuables | issuables | specTitle | returnValue
${mockCheckedIssuables} | ${mIssuables} | ${'same as'} | ${true}
${{}} | ${mIssuables} | ${'not same as'} | ${false}
`(
'returns $returnValue when bulkEditIssuables count is $specTitle issuables count',
async ({ checkedIssuables, issuables, returnValue }) => {
wrapper.setProps({
issuables,
});
await wrapper.vm.$nextTick();
wrapper.setData({
checkedIssuables,
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.allIssuablesChecked).toBe(returnValue);
},
);
});
describe('bulkEditIssuables', () => {
it('returns array of issuables which have `checked` set to true within checkedIssuables map', async () => {
wrapper.setData({
checkedIssuables: mockCheckedIssuables,
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.bulkEditIssuables).toHaveLength(mIssuables.length);
});
});
});
describe('watch', () => {
describe('issuables', () => {
it('populates `checkedIssuables` prop with all issuables', async () => {
wrapper.setProps({
issuables: [mockIssuables[0]],
});
await wrapper.vm.$nextTick();
expect(Object.keys(wrapper.vm.checkedIssuables)).toHaveLength(1);
expect(wrapper.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({
checked: false,
issuable: mockIssuables[0],
});
});
});
describe('urlParams', () => {
it('updates window URL reflecting props within `urlParams`', async () => {
const urlParams = {
......@@ -82,6 +146,30 @@ describe('IssuableListRoot', () => {
});
});
describe('methods', () => {
describe('issuableId', () => {
it('returns id value from provided issuable object', () => {
expect(wrapper.vm.issuableId({ id: 1 })).toBe(1);
expect(wrapper.vm.issuableId({ iid: 1 })).toBe(1);
expect(wrapper.vm.issuableId({})).toBeDefined();
});
});
describe('issuableChecked', () => {
it('returns boolean value representing checked status of issuable item', async () => {
wrapper.setData({
checkedIssuables: {
[mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] },
},
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.issuableChecked(mockIssuables[0])).toBe(true);
});
});
});
describe('template', () => {
it('renders component container element with class "issuable-list-container"', () => {
expect(wrapper.classes()).toContain('issuable-list-container');
......@@ -183,12 +271,44 @@ describe('IssuableListRoot', () => {
});
describe('events', () => {
let wrapperChecked;
beforeEach(() => {
wrapperChecked = createComponent({
data: {
checkedIssuables: {
[mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] },
},
},
});
});
afterEach(() => {
wrapperChecked.destroy();
});
it('issuable-tabs component emits `click-tab` event on `click-tab` event', () => {
wrapper.find(IssuableTabs).vm.$emit('click');
expect(wrapper.emitted('click-tab')).toBeTruthy();
});
it('sets all issuables as checked when filtered-search-bar component emits `checked-input` event', async () => {
const searchEl = wrapperChecked.find(FilteredSearchBar);
searchEl.vm.$emit('checked-input', true);
await wrapperChecked.vm.$nextTick();
expect(searchEl.emitted('checked-input')).toBeTruthy();
expect(searchEl.emitted('checked-input').length).toBe(1);
expect(wrapperChecked.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({
checked: true,
issuable: mockIssuables[0],
});
});
it('filtered-search-bar component emits `filter` event on `onFilter` & `sort` event on `onSort` events', () => {
const searchEl = wrapper.find(FilteredSearchBar);
......@@ -198,6 +318,22 @@ describe('IssuableListRoot', () => {
expect(wrapper.emitted('sort')).toBeTruthy();
});
it('sets an issuable as checked when issuable-item component emits `checked-input` event', async () => {
const issuableItem = wrapperChecked.findAll(IssuableItem).at(0);
issuableItem.vm.$emit('checked-input', true);
await wrapperChecked.vm.$nextTick();
expect(issuableItem.emitted('checked-input')).toBeTruthy();
expect(issuableItem.emitted('checked-input').length).toBe(1);
expect(wrapperChecked.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({
checked: true,
issuable: mockIssuables[0],
});
});
it('gl-pagination component emits `page-change` event on `input` event', async () => {
wrapper.setProps({
showPaginationControls: true,
......
import { shallowMount, mount } from '@vue/test-utils';
import { GlFilteredSearch, GlButtonGroup, GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import {
GlFilteredSearch,
GlButtonGroup,
GlButton,
GlDropdown,
GlDropdownItem,
GlFormCheckbox,
} from '@gitlab/ui';
import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import { uniqueTokens } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
......@@ -30,6 +37,8 @@ const createComponent = ({
recentSearchesStorageKey = 'requirements',
tokens = mockAvailableTokens,
sortOptions,
showCheckbox = false,
checkboxChecked = false,
searchInputPlaceholder = 'Filter requirements',
} = {}) => {
const mountMethod = shallow ? shallowMount : mount;
......@@ -40,6 +49,8 @@ const createComponent = ({
recentSearchesStorageKey,
tokens,
sortOptions,
showCheckbox,
checkboxChecked,
searchInputPlaceholder,
},
});
......@@ -364,6 +375,26 @@ describe('FilteredSearchBarRoot', () => {
expect(glFilteredSearchEl.props('historyItems')).toEqual(mockHistoryItems);
});
it('renders checkbox when `showCheckbox` prop is true', async () => {
let wrapperWithCheckbox = createComponent({
showCheckbox: true,
});
expect(wrapperWithCheckbox.find(GlFormCheckbox).exists()).toBe(true);
expect(wrapperWithCheckbox.find(GlFormCheckbox).attributes('checked')).not.toBeDefined();
wrapperWithCheckbox.destroy();
wrapperWithCheckbox = createComponent({
showCheckbox: true,
checkboxChecked: true,
});
expect(wrapperWithCheckbox.find(GlFormCheckbox).attributes('checked')).toBe('true');
wrapperWithCheckbox.destroy();
});
it('renders search history items dropdown with formatting done using token symbols', async () => {
const wrapperFullMount = createComponent({ sortOptions: mockSortOptions, shallow: false });
wrapperFullMount.vm.recentSearchesStore.addRecentSearch(mockHistoryItems[0]);
......
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