Commit c0b52867 authored by Coung Ngo's avatar Coung Ngo Committed by Kushal Pandya

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
......@@ -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);
......@@ -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';
......@@ -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);
......@@ -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');
......
......@@ -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"
......
......@@ -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 });
......@@ -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';
......@@ -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.
......
......@@ -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 {
......
......@@ -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
......
......@@ -101,6 +101,20 @@
= dropdown_content do
.js-due-date-calendar
- 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' } }
......
......@@ -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 ""
......
......@@ -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')
......@@ -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
......@@ -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
......@@ -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
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
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
......
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));
});
});
});
......@@ -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);
......
......@@ -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 }];
......
......@@ -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 }];
......
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