Commit 45208b2b authored by Scott Stern's avatar Scott Stern Committed by Denys Mishunov

Add iteration title/value to Issue sidebar

Add iterations to sidebar to select an iteration the
issue should be assigned to
parent 7f0febfc
......@@ -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
......
This diff is collapsed.
......@@ -2937,6 +2937,9 @@ msgstr ""
msgid "Assign"
msgstr ""
msgid "Assign Iteration"
msgstr ""
msgid "Assign custom color like #FF0000"
msgstr ""
......@@ -9461,6 +9464,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 ""
......@@ -12464,6 +12470,9 @@ msgstr ""
msgid "It's you"
msgstr ""
msgid "Iteration"
msgstr ""
msgid "Iteration changed to"
msgstr ""
......@@ -14894,6 +14903,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