Commit 8c1c9585 authored by Denys Mishunov's avatar Denys Mishunov

Merge branch 'ss/iterations' into 'master'

Add iteration title/value to Issue sidebar

See merge request gitlab-org/gitlab!32361
parents 41abe00d 45208b2b
......@@ -53,7 +53,8 @@
.selectbox.hide-collapsed
= f.hidden_field 'milestone_id', value: milestone[:id], id: nil
= dropdown_tag('Milestone', options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: _('Search milestones'), data: { show_no: true, field_name: "#{issuable_type}[milestone_id]", project_id: issuable_sidebar[:project_id], issuable_id: issuable_sidebar[:id], milestones: issuable_sidebar[:project_milestones_path], ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], use_id: true, default_no: true, selected: milestone[:title], null_default: true, display: 'static' }})
- if @project.group.present?
= render_if_exists 'shared/issuable/iteration_select', { can_edit: can_edit_issuable, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type }
#issuable-time-tracker.block
// Fallback while content is loading
.title.hide-collapsed
......
<script>
import {
GlButton,
GlLink,
GlNewDropdown,
GlNewDropdownItem,
GlSearchBoxByType,
GlNewDropdownHeader,
GlIcon,
GlTooltipDirective,
} from '@gitlab/ui';
import groupIterationsQuery from '../queries/group_iterations.query.graphql';
import currentIterationQuery from '../queries/issue_iteration.query.graphql';
import setIssueIterationMutation from '../queries/set_iteration_on_issue.mutation.graphql';
import { iterationSelectTextMap } from '../constants';
import createFlash from '~/flash';
export default {
noIteration: iterationSelectTextMap.noIteration,
iterationText: iterationSelectTextMap.iteration,
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
GlButton,
GlLink,
GlNewDropdown,
GlNewDropdownItem,
GlSearchBoxByType,
GlNewDropdownHeader,
GlIcon,
},
props: {
canEdit: {
required: true,
type: Boolean,
},
groupPath: {
required: true,
type: String,
},
projectPath: {
required: true,
type: String,
},
issueIid: {
required: true,
type: String,
},
},
apollo: {
currentIteration: {
query: currentIterationQuery,
variables() {
return {
fullPath: this.projectPath,
iid: this.issueIid,
};
},
update(data) {
return data?.project?.issue?.iteration?.id;
},
},
iterations: {
query: groupIterationsQuery,
debounce: 250,
variables() {
// TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/220381
const search = this.searchTerm === '' ? '' : `"${this.searchTerm}"`;
return {
fullPath: this.groupPath,
title: search,
};
},
update(data) {
// TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/220379
const nodes = data.group?.iterations?.nodes || [];
return iterationSelectTextMap.noIterationItem.concat(nodes);
},
},
},
data() {
return {
searchTerm: '',
editing: false,
currentIteration: undefined,
iterations: iterationSelectTextMap.noIterationItem,
};
},
computed: {
iteration() {
// NOTE: Optional chaining guards when search result is empty
return this.iterations.find(({ id }) => id === this.currentIteration)?.title;
},
showNoIterationContent() {
return !this.editing && !this.currentIteration;
},
},
mounted() {
document.addEventListener('click', this.handleOffClick);
},
beforeDestroy() {
document.removeEventListener('click', this.handleOffClick);
},
methods: {
toggleDropdown() {
this.editing = !this.editing;
this.$nextTick(() => {
if (this.editing) {
this.$refs.search.focusInput();
}
});
},
setIteration(iterationId) {
if (iterationId === this.currentIteration) return;
this.editing = false;
this.$apollo
.mutate({
mutation: setIssueIterationMutation,
variables: {
projectPath: this.projectPath,
iterationId,
iid: this.issueIid,
},
})
.then(({ data }) => {
if (data.issueSetIteration?.errors?.length) {
createFlash(data.issueSetIteration.errors[0]);
} else {
this.currentIteration = data.issueSetIteration?.issue?.iteration?.id;
}
})
.catch(() => {
const { iterationSelectFail } = iterationSelectTextMap;
createFlash(iterationSelectFail);
});
},
handleOffClick(event) {
if (!this.editing) return;
if (!this.$refs.newDropdown.$el.contains(event.target)) {
this.toggleDropdown(event);
}
},
isIterationChecked(iterationId = undefined) {
return iterationId === this.currentIteration || (!this.currentIteration && !iterationId);
},
},
};
</script>
<template>
<div class="mt-3">
<div v-gl-tooltip class="sidebar-collapsed-icon">
<gl-icon :size="16" :aria-label="$options.iterationText" name="iteration" />
<span class="collapse-truncated-title">{{ iteration }}</span>
</div>
<div class="title hide-collapsed">
{{ $options.iterationText }}
<gl-button
v-if="canEdit"
variant="link"
class="js-sidebar-dropdown-toggle edit-link gl-shadow-none float-right"
data-testid="iteration-edit-link"
data-track-label="right_sidebar"
data-track-property="iteration"
data-track-event="click_edit_button"
@click.stop="toggleDropdown"
>{{ __('Edit') }}</gl-button
>
</div>
<div data-testid="select-iteration" class="hide-collapsed">
<span v-if="showNoIterationContent" class="no-value">{{ $options.noIteration }}</span>
<gl-link v-else-if="!editing" href
><strong>{{ iteration }}</strong></gl-link
>
</div>
<gl-new-dropdown
v-show="editing"
ref="newDropdown"
data-toggle="dropdown"
:text="$options.iterationText"
class="dropdown gl-w-full"
:class="{ show: editing }"
>
<gl-new-dropdown-header class="d-flex justify-content-center">{{
__('Assign Iteration')
}}</gl-new-dropdown-header>
<gl-search-box-by-type ref="search" v-model="searchTerm" class="gl-m-3" />
<gl-new-dropdown-item
v-for="iterationItem in iterations"
:key="iterationItem.id"
:is-check-item="true"
:is-checked="isIterationChecked(iterationItem.id)"
@click="setIteration(iterationItem.id)"
>{{ iterationItem.title }}</gl-new-dropdown-item
>
</gl-new-dropdown>
</div>
</template>
......@@ -11,3 +11,10 @@ export const healthStatusTextMap = {
[healthStatus.NEEDS_ATTENTION]: __('Needs attention'),
[healthStatus.AT_RISK]: __('At risk'),
};
export const iterationSelectTextMap = {
iteration: __('Iteration'),
noIteration: __('No iteration'),
noIterationItem: [{ title: __('No iteration'), id: null }],
iterationSelectFail: __('Failed to set iteration on this issue. Please try again.'),
};
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { parseBoolean } from '~/lib/utils/common_utils';
import * as CEMountSidebar from '~/sidebar/mount_sidebar';
import SidebarItemEpicsSelect from './components/sidebar_item_epics_select.vue';
import SidebarStatus from './components/status/sidebar_status.vue';
import SidebarWeight from './components/weight/sidebar_weight.vue';
import IterationSelect from './components/iteration_select.vue';
import SidebarStore from './stores/sidebar_store';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
const mountWeightComponent = mediator => {
const el = document.querySelector('.js-sidebar-weight-entry-point');
......@@ -72,9 +77,40 @@ const mountEpicsSelect = () => {
});
};
function mountIterationSelect() {
const el = document.querySelector('.js-iteration-select');
if (!el) {
return false;
}
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
const { groupPath, canEdit, projectPath, issueIid } = el.dataset;
return new Vue({
el,
apolloProvider,
components: {
IterationSelect,
},
render: createElement =>
createElement('iteration-select', {
props: {
groupPath,
canEdit,
projectPath,
issueIid,
},
}),
});
}
export default function mountSidebar(mediator) {
CEMountSidebar.mountSidebar(mediator);
mountWeightComponent(mediator);
mountStatusComponent(mediator);
mountEpicsSelect();
mountIterationSelect();
}
query groupIterations($fullPath: ID!, $title: String) {
group(fullPath: $fullPath) {
iterations (title: $title) {
nodes {
id
title
state
}
}
}
}
query issueSprint($fullPath: ID!, $iid: String!) {
project(fullPath: $fullPath) {
issue (iid: $iid) {
iteration {
id
title
}
}
}
}
mutation updateIssueConfidential($projectPath: ID!, $iid: String!, $iterationId: ID) {
issueSetIteration(input: {projectPath: $projectPath, iid: $iid, iterationId: $iterationId}) {
errors
issue {
iteration {
title
id
state
}
}
}
}
- if @project.group.beta_feature_available?(:iterations) && issuable_type == "issue"
.js-iteration-select{ data: { can_edit: can_edit, group_path: group_path, project_path: project_path, issue_iid: issue_iid } }
......@@ -5,11 +5,13 @@ require 'spec_helper'
RSpec.describe 'Issue Sidebar' do
include MobileHelpers
let(:group) { create(:group, :nested) }
let(:project) { create(:project, :public, namespace: group) }
let!(:user) { create(:user)}
let!(:label) { create(:label, project: project, title: 'bug') }
let(:issue) { create(:labeled_issue, project: project, labels: [label]) }
let_it_be(:group) { create(:group, :nested) }
let_it_be(:project) { create(:project, :public, namespace: group) }
let_it_be(:project_without_group) { create(:project, :public) }
let_it_be(:user) { create(:user)}
let_it_be(:label) { create(:label, project: project, title: 'bug') }
let_it_be(:issue) { create(:labeled_issue, project: project, labels: [label]) }
let_it_be(:issue_no_group) { create(:labeled_issue, project: project_without_group, labels: [label]) }
before do
sign_in(user)
......@@ -128,6 +130,79 @@ RSpec.describe 'Issue Sidebar' do
end
end
context 'Iterations', :js do
context 'when iterations feature available' do
let_it_be(:iteration) { create(:iteration, group: group, start_date: 1.day.from_now, due_date: 2.days.from_now, title: 'Iteration 1') }
before do
iteration
stub_licensed_features(iterations: true)
project.add_developer(user)
visit_issue(project, issue)
wait_for_all_requests
end
it 'selects and updates the right iteration' do
find_and_click_edit_iteration
select_iteration(iteration.title)
expect(page.find('[data-testid="select-iteration"]')).to have_content('Iteration 1')
find_and_click_edit_iteration
select_iteration('No iteration')
expect(page.find('[data-testid="select-iteration"]')).to have_content('No iteration')
end
end
context 'when a project does not have a group' do
before do
stub_licensed_features(iterations: true)
project_without_group.add_developer(user)
visit_issue(project_without_group, issue_no_group)
wait_for_all_requests
end
it 'cannot find the select-iteration edit button' do
expect(page).not_to have_selector('[data-testid="select-iteration"]')
end
end
context 'when iteration feature is not available' do
before do
stub_licensed_features(iterations: false)
project.add_developer(user)
visit_issue(project, issue)
wait_for_all_requests
end
it 'cannot find the select-iteration edit button' do
expect(page).not_to have_selector('[data-testid="select-iteration"]')
end
end
end
def find_and_click_edit_iteration
page.find('[data-testid="iteration-edit-link"]').click
end
def select_iteration(iteration_name)
click_button(iteration_name)
wait_for_all_requests
end
def visit_issue(project, issue)
visit project_issue_path(project, issue)
end
......
import { shallowMount } from '@vue/test-utils';
import { GlNewDropdown, GlNewDropdownItem, GlButton, GlSearchBoxByType } from '@gitlab/ui';
import createFlash from '~/flash';
import IterationSelect from 'ee/sidebar/components/iteration_select.vue';
import { iterationSelectTextMap } from 'ee/sidebar/constants';
import setIterationOnIssue from 'ee/sidebar/queries/set_iteration_on_issue.mutation.graphql';
jest.mock('~/flash');
describe('IterationSelect', () => {
let wrapper;
const promiseData = { issueSetIteration: { issue: { iteration: { id: '123' } } } };
const firstErrorMsg = 'first error';
const promiseWithErrors = {
...promiseData,
issueSetIteration: { ...promiseData.issueSetIteration, errors: [firstErrorMsg] },
};
const mutationSuccess = () => jest.fn().mockResolvedValue({ data: promiseData });
const mutationError = () => jest.fn().mockRejectedValue();
const mutationSuccessWithErrors = () => jest.fn().mockResolvedValue({ data: promiseWithErrors });
const toggleDropdown = (spy = () => {}) =>
wrapper.find(GlButton).vm.$emit('click', { stopPropagation: spy });
const createComponent = ({
data = {},
mutationPromise = mutationSuccess,
props = { canEdit: true },
}) => {
wrapper = shallowMount(IterationSelect, {
data() {
return data;
},
propsData: {
...props,
groupPath: '',
projectPath: '',
issueIid: '',
},
mocks: {
$options: {
noIterationItem: [],
},
$apollo: {
mutate: mutationPromise(),
},
},
stubs: {
GlSearchBoxByType,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when not editing', () => {
it('shows the current iteration', () => {
createComponent({
data: { iterations: [{ id: 'id', title: 'title' }], currentIteration: 'id' },
});
expect(wrapper.find('[data-testid="select-iteration"]').text()).toBe('title');
});
});
describe('when a user cannot edit', () => {
it('cannot find the edit button', () => {
createComponent({ props: { canEdit: false } });
expect(wrapper.find(GlButton).exists()).toBe(false);
});
});
describe('when a user can edit', () => {
it('opens the dropdown on click of the edit button', () => {
createComponent({ props: { canEdit: true } });
expect(wrapper.find(GlNewDropdown).isVisible()).toBe(false);
toggleDropdown();
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(GlNewDropdown).isVisible()).toBe(true);
});
});
it('focuses on the input', () => {
createComponent({ props: { canEdit: true } });
const spy = jest.spyOn(wrapper.vm.$refs.search, 'focusInput');
toggleDropdown();
return wrapper.vm.$nextTick().then(() => {
expect(spy).toHaveBeenCalled();
});
});
it('stops propagation of the click event to avoid opening milestone dropdown', () => {
const spy = jest.fn();
createComponent({ props: { canEdit: true } });
expect(wrapper.find(GlNewDropdown).isVisible()).toBe(false);
toggleDropdown(spy);
return wrapper.vm.$nextTick().then(() => {
expect(spy).toHaveBeenCalledTimes(1);
});
});
describe('when user is editing', () => {
describe('when rendering the dropdown', () => {
it('shows GlNewDropdown', () => {
createComponent({ props: { canEdit: true }, data: { editing: true } });
expect(wrapper.find(GlNewDropdown).isVisible()).toBe(true);
});
describe('GlDropdownItem with the right title and id', () => {
const id = 'id';
const title = 'title';
beforeEach(() => {
createComponent({ data: { iterations: [{ id, title }], currentIteration: id } });
});
it('renders title $title', () => {
expect(
wrapper
.findAll(GlNewDropdownItem)
.filter(w => w.text() === title)
.at(0)
.text(),
).toBe(title);
});
it('checks the correct dropdown item', () => {
expect(
wrapper
.findAll(GlNewDropdownItem)
.filter(w => w.props('isChecked') === true)
.at(0)
.text(),
).toBe(title);
});
});
describe('when no data is assigned', () => {
beforeEach(() => {
createComponent({});
});
it('finds GlNewDropdownItem with "No iteration"', () => {
expect(wrapper.find(GlNewDropdownItem).text()).toBe('No iteration');
});
it('"No iteration" is checked', () => {
expect(wrapper.find(GlNewDropdownItem).props('isChecked')).toBe(true);
});
});
describe('when clicking on dropdown item', () => {
describe('when currentIteration is equal to iteration id', () => {
it('does not call setIssueIteration mutation', () => {
createComponent({
data: { iterations: [{ id: 'id', title: 'title' }], currentIteration: 'id' },
});
wrapper
.findAll(GlNewDropdownItem)
.filter(w => w.text() === 'title')
.at(0)
.vm.$emit('click');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(0);
});
});
describe('when currentIteration is not equal to iteration id', () => {
describe('when success', () => {
beforeEach(() => {
createComponent({
data: {
iterations: [{ id: 'id', title: 'title' }, { id: '123', title: '123' }],
currentIteration: '123',
},
});
wrapper
.findAll(GlNewDropdownItem)
.filter(w => w.text() === 'title')
.at(0)
.vm.$emit('click');
});
it('calls setIssueIteration mutation', () => {
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: setIterationOnIssue,
variables: { projectPath: '', iterationId: 'id', iid: '' },
});
});
it('sets the value returned from the mutation to currentIteration', () => {
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.currentIteration).toBe('123');
});
});
});
describe('when error', () => {
const bootstrapComponent = mutationResp => {
createComponent({
data: {
iterations: [{ id: '123', title: '123' }, { id: 'id', title: 'title' }],
currentIteration: '123',
},
mutationPromise: mutationResp,
});
};
describe.each`
description | mutationResp | expectedMsg
${'top-level error'} | ${mutationError} | ${iterationSelectTextMap.iterationSelectFail}
${'user-recoverable error'} | ${mutationSuccessWithErrors} | ${firstErrorMsg}
`(`$description`, ({ mutationResp, expectedMsg }) => {
beforeEach(() => {
bootstrapComponent(mutationResp);
wrapper
.findAll(GlNewDropdownItem)
.filter(w => w.text() === 'title')
.at(0)
.vm.$emit('click');
});
it('calls createFlash with $expectedMsg', () => {
return wrapper.vm.$nextTick().then(() => {
expect(createFlash).toHaveBeenCalledWith(expectedMsg);
});
});
});
});
});
});
});
describe('when a user is searching', () => {
beforeEach(() => {
createComponent({});
});
it('sets the search term', () => {
wrapper.find(GlSearchBoxByType).vm.$emit('input', 'testing');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.searchTerm).toBe('testing');
});
});
});
describe('when the user off clicks', () => {
describe('when the dropdown is open', () => {
beforeEach(() => {
createComponent({});
toggleDropdown();
return wrapper.vm.$nextTick();
});
it('closes the dropdown', () => {
expect(wrapper.find(GlNewDropdown).isVisible()).toBe(true);
toggleDropdown();
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(GlNewDropdown).isVisible()).toBe(false);
});
});
});
});
});
describe('apollo schema', () => {
describe('iterations', () => {
describe('when iterations is passed the wrong data object', () => {
beforeEach(() => {
createComponent({});
});
it.each([
[{}, iterationSelectTextMap.noIterationItem],
[{ group: {} }, iterationSelectTextMap.noIterationItem],
[{ group: { iterations: {} } }, iterationSelectTextMap.noIterationItem],
[
{ group: { iterations: { nodes: ['nodes'] } } },
[...iterationSelectTextMap.noIterationItem, 'nodes'],
],
])('when %j as an argument it returns %j', (data, value) => {
const { update } = wrapper.vm.$options.apollo.iterations;
expect(update(data)).toEqual(value);
});
});
it('contains debounce', () => {
createComponent({});
const { debounce } = wrapper.vm.$options.apollo.iterations;
expect(debounce).toBe(250);
});
it('returns the correct values based on the schema', () => {
createComponent({});
const { update } = wrapper.vm.$options.apollo.iterations;
// needed to access this.$options in update
const boundUpdate = update.bind(wrapper.vm);
expect(boundUpdate({ group: { iterations: { nodes: [] } } })).toEqual(
iterationSelectTextMap.noIterationItem,
);
});
});
describe('currentIteration', () => {
describe('when passes an object that doesnt contain the correct values', () => {
beforeEach(() => {
createComponent({});
});
it.each([
[{}, undefined],
[{ project: { issue: {} } }, undefined],
[{ project: { issue: { iteration: {} } } }, undefined],
])('when %j as an argument it returns %j', (data, value) => {
const { update } = wrapper.vm.$options.apollo.currentIteration;
expect(update(data)).toBe(value);
});
});
describe('when iteration has an id', () => {
it('returns the id', () => {
createComponent({});
const { update } = wrapper.vm.$options.apollo.currentIteration;
expect(update({ project: { issue: { iteration: { id: '123' } } } })).toEqual('123');
});
});
});
});
});
});
......@@ -2940,6 +2940,9 @@ msgstr ""
msgid "Assign"
msgstr ""
msgid "Assign Iteration"
msgstr ""
msgid "Assign custom color like #FF0000"
msgstr ""
......@@ -9464,6 +9467,9 @@ msgstr ""
msgid "Failed to set due date because the date format is invalid."
msgstr ""
msgid "Failed to set iteration on this issue. Please try again."
msgstr ""
msgid "Failed to signing using smartcard authentication"
msgstr ""
......@@ -12467,6 +12473,9 @@ msgstr ""
msgid "It's you"
msgstr ""
msgid "Iteration"
msgstr ""
msgid "Iteration changed to"
msgstr ""
......@@ -14897,6 +14906,9 @@ msgstr ""
msgid "No grouping"
msgstr ""
msgid "No iteration"
msgstr ""
msgid "No iterations to show"
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