Skip to content
Snippets Groups Projects
Commit c0b52867 authored by Coung Ngo's avatar Coung Ngo :guitar: Committed by Kushal Pandya
Browse files

Convert Issue sidebar labels to Vue

Issue sidebar labels was entirely in Haml. By converting it
to Vue, we can use gitlab-ui components
parent a90559bd
No related branches found
No related tags found
No related merge requests found
Showing
with 360 additions and 49 deletions
Loading
Loading
@@ -4,8 +4,8 @@ import MilestoneSelect from './milestone_select';
import LabelsSelect from './labels_select';
import IssuableContext from './issuable_context';
import Sidebar from './right_sidebar';
import DueDateSelectors from './due_date_select';
import { mountSidebarLabels } from '~/sidebar/mount_sidebar';
 
export default () => {
const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
Loading
Loading
@@ -17,4 +17,6 @@ export default () => {
new IssuableContext(sidebarOptions.currentUser);
new DueDateSelectors();
Sidebar.initialize();
mountSidebarLabels();
};
<script>
import $ from 'jquery';
import { difference, union } from 'lodash';
import { mapState, mapActions } from 'vuex';
import flash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
export default {
components: {
LabelsSelect,
},
variant: DropdownVariant.Sidebar,
inject: [
'allowLabelCreate',
'allowLabelEdit',
'allowScopedLabels',
'iid',
'initiallySelectedLabels',
'issuableType',
'labelsFetchPath',
'labelsManagePath',
'labelsUpdatePath',
'projectIssuesPath',
'projectPath',
],
data: () => ({
labelsSelectInProgress: false,
}),
computed: {
...mapState(['selectedLabels']),
},
mounted() {
this.setInitialState({
selectedLabels: this.initiallySelectedLabels,
});
},
methods: {
...mapActions(['setInitialState', 'replaceSelectedLabels']),
handleDropdownClose() {
$(this.$el).trigger('hidden.gl.dropdown');
},
handleUpdateSelectedLabels(labels) {
const currentLabelIds = this.selectedLabels.map(label => label.id);
const userAddedLabelIds = labels.filter(label => label.set).map(label => label.id);
const userRemovedLabelIds = labels.filter(label => !label.set).map(label => label.id);
const issuableLabels = difference(
union(currentLabelIds, userAddedLabelIds),
userRemovedLabelIds,
);
this.labelsSelectInProgress = true;
axios({
data: {
[this.issuableType]: {
label_ids: issuableLabels,
},
},
method: 'put',
url: this.labelsUpdatePath,
})
.then(({ data }) => this.replaceSelectedLabels(data.labels))
.catch(() => flash(__('An error occurred while updating labels.')))
.finally(() => {
this.labelsSelectInProgress = false;
});
},
},
};
</script>
<template>
<labels-select
class="block labels js-labels-block"
:allow-label-create="allowLabelCreate"
:allow-label-edit="allowLabelEdit"
:allow-multiselect="true"
:allow-scoped-labels="allowScopedLabels"
:footer-create-label-title="__('Create project label')"
:footer-manage-label-title="__('Manage project labels')"
:labels-create-title="__('Create project label')"
:labels-fetch-path="labelsFetchPath"
:labels-filter-base-path="projectIssuesPath"
:labels-manage-path="labelsManagePath"
:labels-select-in-progress="labelsSelectInProgress"
:selected-labels="selectedLabels"
:variant="$options.sidebar"
@onDropdownClose="handleDropdownClose"
@updateSelectedLabels="handleUpdateSelectedLabels"
>
{{ __('None') }}
</labels-select>
</template>
import $ from 'jquery';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import SidebarLabels from './components/labels/sidebar_labels.vue';
import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue';
import SidebarMoveIssue from './lib/sidebar_move_issue';
import IssuableLockForm from './components/lock/issuable_lock_form.vue';
Loading
Loading
@@ -12,11 +14,13 @@ import SidebarSeverity from './components/severity/sidebar_severity.vue';
import Translate from '../vue_shared/translate';
import createDefaultClient from '~/lib/graphql';
import { store } from '~/notes/stores';
import { isInIssuePage } from '~/lib/utils/common_utils';
import { isInIssuePage, parseBoolean } from '~/lib/utils/common_utils';
import mergeRequestStore from '~/mr_notes/stores';
import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
 
Vue.use(Translate);
Vue.use(VueApollo);
Vue.use(Vuex);
 
function getSidebarOptions() {
return JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
Loading
Loading
@@ -52,6 +56,29 @@ function mountAssigneesComponent(mediator) {
});
}
 
export function mountSidebarLabels() {
const el = document.querySelector('.js-sidebar-labels');
if (!el) {
return false;
}
const labelsStore = new Vuex.Store(labelsSelectModule());
return new Vue({
el,
provide: {
...el.dataset,
allowLabelCreate: parseBoolean(el.dataset.allowLabelCreate),
allowLabelEdit: parseBoolean(el.dataset.canEdit),
allowScopedLabels: parseBoolean(el.dataset.allowScopedLabels),
initiallySelectedLabels: JSON.parse(el.dataset.selectedLabels),
},
store: labelsStore,
render: createElement => createElement(SidebarLabels),
});
}
function mountConfidentialComponent(mediator) {
const el = document.getElementById('js-confidential-entry-point');
 
Loading
Loading
Loading
Loading
@@ -252,10 +252,10 @@ export default {
:allow-label-edit="allowLabelEdit"
:labels-select-in-progress="labelsSelectInProgress"
/>
<dropdown-value v-show="!showDropdownButton">
<dropdown-value>
<slot></slot>
</dropdown-value>
<dropdown-button v-show="dropdownButtonVisible" />
<dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" />
<dropdown-contents
v-if="dropdownButtonVisible && showDropdownContents"
ref="dropdownContents"
Loading
Loading
Loading
Loading
@@ -54,5 +54,8 @@ export const createLabel = ({ state, dispatch }, label) => {
});
};
 
export const replaceSelectedLabels = ({ commit }, selectedLabels) =>
commit(types.REPLACE_SELECTED_LABELS, selectedLabels);
export const updateSelectedLabels = ({ commit }, labels) =>
commit(types.UPDATE_SELECTED_LABELS, { labels });
Loading
Loading
@@ -15,6 +15,7 @@ export const RECEIVE_CREATE_LABEL_FAILURE = 'RECEIVE_CREATE_LABEL_FAILURE';
export const TOGGLE_DROPDOWN_BUTTON = 'TOGGLE_DROPDOWN_VISIBILITY';
export const TOGGLE_DROPDOWN_CONTENTS = 'TOGGLE_DROPDOWN_CONTENTS';
 
export const REPLACE_SELECTED_LABELS = 'REPLACE_SELECTED_LABELS';
export const UPDATE_SELECTED_LABELS = 'UPDATE_SELECTED_LABELS';
 
export const TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW = 'TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW';
Loading
Loading
@@ -57,6 +57,10 @@ export default {
state.labelCreateInProgress = false;
},
 
[types.REPLACE_SELECTED_LABELS](state, selectedLabels = []) {
state.selectedLabels = selectedLabels;
},
[types.UPDATE_SELECTED_LABELS](state, { labels }) {
// Find the label to update from all the labels
// and change `set` prop value to represent their current state.
Loading
Loading
Loading
Loading
@@ -145,6 +145,13 @@
.value.dont-hide ~ .selectbox {
padding-top: $gl-padding-8;
}
// This is for sidebar components using gl-button for the Edit button to be consistent with the
// rest of the sidebar, and could be removed once the sidebar has been fully converted to use
// gitlab-ui components.
.title .gl-button {
color: $gl-text-color;
}
}
 
.pikaday-container {
Loading
Loading
Loading
Loading
@@ -45,6 +45,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:tribute_autocomplete, @project)
push_frontend_feature_flag(:vue_issuables_list, project)
push_frontend_feature_flag(:design_management_todo_button, project, default_enabled: true)
push_frontend_feature_flag(:vue_sidebar_labels, @project)
end
 
before_action only: :show do
Loading
Loading
Loading
Loading
@@ -101,36 +101,50 @@
= dropdown_content do
.js-due-date-calendar
 
- selected_labels = issuable_sidebar[:labels]
.block.labels
.sidebar-collapsed-icon.js-sidebar-labels-tooltip{ title: issuable_labels_tooltip(selected_labels), data: { placement: "left", container: "body", boundary: 'viewport' } }
= sprite_icon('labels')
%span
= selected_labels.size
.title.hide-collapsed
= _('Labels')
= loading_icon(css_class: 'gl-vertical-align-text-bottom hidden block-loading')
- if can_edit_issuable
= link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { qa_selector: "edit_labels_link", track_label: "right_sidebar", track_property: "labels", track_event: "click_edit_button", track_value: "" }
.value.issuable-show-labels.dont-hide.hide-collapsed{ class: ("has-labels" if selected_labels.any?), data: { qa_selector: 'labels_block' } }
- if selected_labels.any?
- selected_labels.each do |label_hash|
= render_label(label_from_hash(label_hash).present(issuable_subject: nil), link: sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label_hash[:title]), dataset: { qa_selector: 'label', qa_label_name: label_hash[:title] })
- else
%span.no-value
= _('None')
.selectbox.hide-collapsed
- selected_labels.each do |label|
= hidden_field_tag "#{issuable_type}[label_names][]", label[:id], id: nil
.dropdown
%button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: sidebar_label_dropdown_data(issuable_type, issuable_sidebar) }
%span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) }
= multi_label_name(selected_labels, "Labels")
= icon('chevron-down', 'aria-hidden': 'true')
.dropdown-menu.dropdown-select.dropdown-menu-paging.qa-dropdown-menu-labels.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height
= render partial: "shared/issuable/label_page_default"
- if issuable_sidebar.dig(:current_user, :can_admin_label)
= render partial: "shared/issuable/label_page_create"
- if Feature.enabled?(:vue_sidebar_labels, @project)
.js-sidebar-labels{ data: { allow_label_create: issuable_sidebar.dig(:current_user, :can_admin_label).to_s,
allow_scoped_labels: issuable_sidebar[:scoped_labels_available].to_s,
can_edit: can_edit_issuable.to_s,
iid: issuable_sidebar[:iid],
issuable_type: issuable_type,
labels_fetch_path: issuable_sidebar[:project_labels_path],
labels_manage_path: project_labels_path(@project),
labels_update_path: issuable_sidebar[:issuable_json_path],
project_issues_path: issuable_sidebar[:project_issuables_path],
project_path: @project.full_path,
selected_labels: issuable_sidebar[:labels].to_json } }
- else
- selected_labels = issuable_sidebar[:labels]
.block.labels
.sidebar-collapsed-icon.js-sidebar-labels-tooltip{ title: issuable_labels_tooltip(selected_labels), data: { placement: "left", container: "body", boundary: 'viewport' } }
= sprite_icon('labels')
%span
= selected_labels.size
.title.hide-collapsed
= _('Labels')
= loading_icon(css_class: 'gl-vertical-align-text-bottom hidden block-loading')
- if can_edit_issuable
= link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { qa_selector: "edit_labels_link", track_label: "right_sidebar", track_property: "labels", track_event: "click_edit_button", track_value: "" }
.value.issuable-show-labels.dont-hide.hide-collapsed{ class: ("has-labels" if selected_labels.any?), data: { qa_selector: 'labels_block' } }
- if selected_labels.any?
- selected_labels.each do |label_hash|
= render_label(label_from_hash(label_hash).present(issuable_subject: nil), link: sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label_hash[:title]), dataset: { qa_selector: 'label', qa_label_name: label_hash[:title] })
- else
%span.no-value
= _('None')
.selectbox.hide-collapsed
- selected_labels.each do |label|
= hidden_field_tag "#{issuable_type}[label_names][]", label[:id], id: nil
.dropdown
%button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: sidebar_label_dropdown_data(issuable_type, issuable_sidebar) }
%span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) }
= multi_label_name(selected_labels, "Labels")
= icon('chevron-down', 'aria-hidden': 'true')
.dropdown-menu.dropdown-select.dropdown-menu-paging.qa-dropdown-menu-labels.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height
= render partial: "shared/issuable/label_page_default"
- if issuable_sidebar.dig(:current_user, :can_admin_label)
= render partial: "shared/issuable/label_page_create"
 
= render_if_exists 'shared/issuable/sidebar_weight', issuable_sidebar: issuable_sidebar
 
Loading
Loading
Loading
Loading
@@ -2951,6 +2951,9 @@ msgstr ""
msgid "An error occurred while updating approvers"
msgstr ""
 
msgid "An error occurred while updating labels."
msgstr ""
msgid "An error occurred while updating the comment"
msgstr ""
 
Loading
Loading
Loading
Loading
@@ -3,7 +3,7 @@
require 'spec_helper'
 
RSpec.describe 'Group label on issue' do
it 'renders link to the project issues page' do
it 'renders link to the project issues page', :js do
group = create(:group)
project = create(:project, :public, namespace: group)
feature = create(:group_label, group: group, title: 'feature')
Loading
Loading
@@ -14,6 +14,6 @@ RSpec.describe 'Group label on issue' do
 
link = find('.issuable-show-labels a')
 
expect(link[:href]).to eq(label_link)
expect(CGI.unescape(link[:href])).to include(CGI.unescape(label_link))
end
end
Loading
Loading
@@ -168,7 +168,7 @@ RSpec.describe 'Issue Sidebar' do
 
it 'escapes XSS when viewing issue labels' do
page.within('.block.labels') do
find('.edit-link').click
click_on 'Edit'
 
expect(page).to have_content '<script>alert("xss");</script>'
end
Loading
Loading
@@ -179,7 +179,7 @@ RSpec.describe 'Issue Sidebar' do
before do
issue.update(labels: [label])
page.within('.block.labels') do
find('.edit-link').click
click_on 'Edit'
end
end
 
Loading
Loading
@@ -286,7 +286,7 @@ RSpec.describe 'Issue Sidebar' do
end
 
it 'does not have a option to edit labels' do
expect(page).not_to have_selector('.block.labels .edit-link')
expect(page).not_to have_selector('.block.labels .js-sidebar-dropdown-toggle')
end
 
context 'interacting with collapsed sidebar', :js do
Loading
Loading
Loading
Loading
@@ -35,12 +35,12 @@ RSpec.describe 'List issue resource label events', :js do
context 'when user adds label to the issue' do
def toggle_labels(labels)
page.within '.labels' do
click_link 'Edit'
click_on 'Edit'
wait_for_requests
 
labels.each { |label| click_link label }
 
click_link 'Edit'
click_on 'Edit'
wait_for_requests
end
end
Loading
Loading
Loading
Loading
@@ -95,11 +95,12 @@ RSpec.describe "Issues > User edits issue", :js do
describe 'update labels' do
it 'will not send ajax request when no data is changed' do
page.within '.labels' do
click_link 'Edit'
click_on 'Edit'
 
find('.dropdown-menu-close', match: :first).click
find('.dropdown-title button').click
 
expect(page).not_to have_selector('.block-loading')
expect(page).not_to have_selector('.gl-spinner')
end
end
end
Loading
Loading
Loading
Loading
@@ -42,12 +42,12 @@ RSpec.describe 'Labels Hierarchy', :js do
 
it 'does not find child group labels on dropdown' do
page.within('.block.labels') do
find('.edit-link').click
end
click_on 'Edit'
 
wait_for_requests
wait_for_requests
 
expect(page).not_to have_selector('.badge', text: child_group_label.title)
expect(page).not_to have_text(child_group_label.title)
end
end
end
 
Loading
Loading
import { createLocalVue, shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import Vuex from 'vuex';
import {
mockLabels,
mockRegularLabel,
} from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data';
import axios from '~/lib/utils/axios_utils';
import SidebarLabels from '~/sidebar/components/labels/sidebar_labels.vue';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('sidebar labels', () => {
let axiosMock;
let wrapper;
const store = new Vuex.Store(labelsSelectModule());
const defaultProps = {
allowLabelCreate: true,
allowLabelEdit: true,
allowScopedLabels: true,
canEdit: true,
iid: '1',
initiallySelectedLabels: mockLabels,
issuableType: 'issue',
labelsFetchPath: '/gitlab-org/gitlab-test/-/labels.json?include_ancestor_groups=true',
labelsManagePath: '/gitlab-org/gitlab-test/-/labels',
labelsUpdatePath: '/gitlab-org/gitlab-test/-/issues/1.json',
projectIssuesPath: '/gitlab-org/gitlab-test/-/issues',
projectPath: 'gitlab-org/gitlab-test',
};
const findLabelsSelect = () => wrapper.find(LabelsSelect);
const mountComponent = () => {
wrapper = shallowMount(SidebarLabels, {
localVue,
provide: {
...defaultProps,
},
store,
});
};
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
axiosMock.restore();
});
describe('LabelsSelect props', () => {
beforeEach(() => {
mountComponent();
});
it('are as expected', () => {
expect(findLabelsSelect().props()).toMatchObject({
allowLabelCreate: defaultProps.allowLabelCreate,
allowLabelEdit: defaultProps.allowLabelEdit,
allowMultiselect: true,
allowScopedLabels: defaultProps.allowScopedLabels,
footerCreateLabelTitle: 'Create project label',
footerManageLabelTitle: 'Manage project labels',
labelsCreateTitle: 'Create project label',
labelsFetchPath: defaultProps.labelsFetchPath,
labelsFilterBasePath: defaultProps.projectIssuesPath,
labelsManagePath: defaultProps.labelsManagePath,
labelsSelectInProgress: false,
selectedLabels: defaultProps.initiallySelectedLabels,
variant: DropdownVariant.Sidebar,
});
});
});
describe('when labels are changed', () => {
beforeEach(() => {
mountComponent();
});
it('makes an API call to update labels', async () => {
const labels = [
{
...mockRegularLabel,
set: false,
},
{
id: 40,
title: 'Security',
color: '#ddd',
text_color: '#fff',
set: true,
},
{
id: 55,
title: 'Tooling',
color: '#ddd',
text_color: '#fff',
set: false,
},
];
findLabelsSelect().vm.$emit('updateSelectedLabels', labels);
await axios.waitForAll();
const expected = {
[defaultProps.issuableType]: {
label_ids: [27, 28, 40],
},
};
expect(axiosMock.history.put[0].data).toEqual(JSON.stringify(expected));
});
});
});
Loading
Loading
@@ -150,11 +150,10 @@ describe('LabelsSelectRoot', () => {
expect(wrapper.find(DropdownTitle).exists()).toBe(true);
});
 
it('renders `dropdown-value` component with slot when `showDropdownButton` prop is `false`', () => {
it('renders `dropdown-value` component', () => {
const wrapperDropdownValue = createComponent(mockConfig, {
default: 'None',
});
wrapperDropdownValue.vm.$store.state.showDropdownButton = false;
 
return wrapperDropdownValue.vm.$nextTick(() => {
const valueComp = wrapperDropdownValue.find(DropdownValue);
Loading
Loading
Loading
Loading
@@ -259,6 +259,21 @@ describe('LabelsSelect Actions', () => {
});
});
 
describe('replaceSelectedLabels', () => {
it('replaces `state.selectedLabels`', done => {
const selectedLabels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
testAction(
actions.replaceSelectedLabels,
selectedLabels,
state,
[{ type: types.REPLACE_SELECTED_LABELS, payload: selectedLabels }],
[],
done,
);
});
});
describe('updateSelectedLabels', () => {
it('updates `state.labels` based on provided `labels` param', done => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
Loading
Loading
Loading
Loading
@@ -152,6 +152,19 @@ describe('LabelsSelect Mutations', () => {
});
});
 
describe(`${types.REPLACE_SELECTED_LABELS}`, () => {
it('replaces `state.selectedLabels`', () => {
const state = {
selectedLabels: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }],
};
const newSelectedLabels = [{ id: 2 }, { id: 5 }];
mutations[types.REPLACE_SELECTED_LABELS](state, newSelectedLabels);
expect(state.selectedLabels).toEqual(newSelectedLabels);
});
});
describe(`${types.UPDATE_SELECTED_LABELS}`, () => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
 
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment