Commit 24c65e64 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'widgetize-iteration-component-v1' into 'master'

Widgetize sidebar iteration component

See merge request gitlab-org/gitlab!54895
parents b2e1609f 39b7fe0e
......@@ -3,7 +3,12 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui';
export default {
components: { GlButton, GlLoadingIcon },
inject: ['canUpdate'],
inject: {
canUpdate: {},
isClassicSidebar: {
default: false,
},
},
props: {
title: {
type: String,
......@@ -83,7 +88,11 @@ export default {
<div class="gl-display-flex gl-align-items-center" @click.self="collapse">
<span class="hide-collapsed" data-testid="title">{{ title }}</span>
<gl-loading-icon v-if="loading" inline class="gl-ml-2 hide-collapsed" />
<gl-loading-icon v-if="loading" inline class="gl-mx-auto gl-my-0 hide-expanded" />
<gl-loading-icon
v-if="loading && isClassicSidebar"
inline
class="gl-mx-auto gl-my-0 hide-expanded"
/>
<gl-button
v-if="canUpdate"
variant="link"
......@@ -92,6 +101,7 @@ export default {
:data-track-event="tracking.event"
:data-track-label="tracking.label"
:data-track-property="tracking.property"
data-qa-selector="edit_link"
@keyup.esc="toggle"
@click="toggle"
>
......@@ -101,7 +111,7 @@ export default {
<div v-show="!edit" data-testid="collapsed-content">
<slot name="collapsed">{{ __('None') }}</slot>
</div>
<div v-show="edit" data-testid="expanded-content">
<div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }">
<slot :edit="edit"></slot>
</div>
</div>
......
......@@ -34,7 +34,7 @@
- if issuable_sidebar[:supports_milestone]
- milestone = issuable_sidebar[:milestone] || {}
.block.milestone{ data: { qa_selector: 'milestone_block' } }
.block.milestone{ class: 'gl-border-b-0!', data: { qa_selector: 'milestone_block' } }
.sidebar-collapsed-icon.has-tooltip{ title: sidebar_milestone_tooltip_label(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } }
= sprite_icon('clock')
%span.milestone-title.collapse-truncated-title
......@@ -58,7 +58,9 @@
.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], 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? && issuable_sidebar[:supports_iterations]
.block{ class: 'gl-pt-0!' }
= render_if_exists 'shared/issuable/iteration_select', can_edit: can_edit_issuable.to_s, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type
- if issuable_sidebar[:supports_time_tracking]
......
<script>
import { GlDrawer } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import SidebarIterationWidget from 'ee/sidebar/components/sidebar_iteration_widget.vue';
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
......@@ -11,7 +12,6 @@ import { contentTop } from '~/lib/utils/common_utils';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import BoardSidebarEpicSelect from './sidebar/board_sidebar_epic_select.vue';
import BoardSidebarIterationSelect from './sidebar/board_sidebar_iteration_select.vue';
import BoardSidebarTimeTracker from './sidebar/board_sidebar_time_tracker.vue';
import BoardSidebarWeightInput from './sidebar/board_sidebar_weight_input.vue';
......@@ -28,12 +28,17 @@ export default {
BoardSidebarDueDate,
BoardSidebarSubscription,
BoardSidebarMilestoneSelect,
BoardSidebarIterationSelect,
SidebarIterationWidget,
},
mixins: [glFeatureFlagsMixin()],
computed: {
...mapGetters(['isSidebarOpen', 'activeIssue']),
...mapState(['sidebarType']),
...mapGetters([
'isSidebarOpen',
'activeIssue',
'groupPathForActiveIssue',
'projectPathForActiveIssue',
]),
...mapState(['sidebarType', 'issuableType']),
isIssuableSidebar() {
return this.sidebarType === ISSUABLE;
},
......@@ -74,7 +79,13 @@ export default {
<board-sidebar-epic-select />
<div>
<board-sidebar-milestone-select />
<board-sidebar-iteration-select class="gl-mt-5" />
<sidebar-iteration-widget
:iid="activeIssue.iid"
:workspace-path="projectPathForActiveIssue"
:iterations-workspace-path="groupPathForActiveIssue"
:issuable-type="issuableType"
class="gl-mt-5"
/>
</div>
<board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" />
<board-sidebar-due-date />
......
<script>
import {
GlLink,
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlSearchBoxByType,
GlDropdownDivider,
GlLoadingIcon,
GlTooltipDirective,
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { mapGetters } from 'vuex';
import {
iterationSelectTextMap,
iterationDisplayState,
noIteration,
edit,
none,
} from 'ee/sidebar/constants';
import groupIterationsQuery from 'ee/sidebar/queries/group_iterations.query.graphql';
import currentIterationQuery from 'ee/sidebar/queries/issue_iteration.query.graphql';
import setIssueIterationMutation from 'ee/sidebar/queries/set_iteration_on_issue.mutation.graphql';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import createFlash from '~/flash';
const debounceValue = 250;
export default {
noIteration,
i18n: {
iteration: iterationSelectTextMap.iteration,
noIteration: iterationSelectTextMap.noIteration,
assignIteration: iterationSelectTextMap.assignIteration,
iterationSelectFail: iterationSelectTextMap.iterationSelectFail,
noIterationsFound: iterationSelectTextMap.noIterationsFound,
currentIterationFetchError: iterationSelectTextMap.currentIterationFetchError,
iterationsFetchError: iterationSelectTextMap.iterationsFetchError,
edit,
none,
},
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
BoardEditableItem,
GlLink,
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlDropdownDivider,
GlSearchBoxByType,
GlLoadingIcon,
},
apollo: {
currentIteration: {
query: currentIterationQuery,
variables() {
return {
fullPath: this.projectPathForActiveIssue,
iid: this.activeIssue.iid,
};
},
update(data) {
return data?.project?.issue.iteration;
},
error(error) {
createFlash({ message: this.$options.i18n.currentIterationFetchError });
Sentry.captureException(error);
},
},
iterations: {
query: groupIterationsQuery,
skip() {
return !this.editing;
},
debounce: debounceValue,
variables() {
const search = this.searchTerm === '' ? '' : `"${this.searchTerm}"`;
return {
fullPath: this.groupPathForActiveIssue,
title: search,
state: iterationDisplayState,
};
},
update(data) {
return data?.group?.iterations.nodes || [];
},
error(error) {
createFlash({ message: this.$options.i18n.iterationsFetchError });
Sentry.captureException(error);
},
},
},
data() {
return {
searchTerm: '',
editing: false,
updating: false,
selectedTitle: null,
currentIteration: null,
iterations: [],
};
},
computed: {
...mapGetters(['activeIssue', 'projectPathForActiveIssue', 'groupPathForActiveIssue']),
showCurrentIteration() {
return this.currentIteration !== null && !this.editing;
},
iteration() {
return this.findIteration(this.currentIteration);
},
iterationTitle() {
return this.currentIteration?.title;
},
iterationUrl() {
return this.currentIteration?.webUrl;
},
dropdownText() {
return this.currentIteration ? this.currentIteration?.title : this.$options.i18n.iteration;
},
showNoIterationContent() {
return !this.updating && !this.currentIteration;
},
loading() {
return this.updating || this.$apollo.queries.currentIteration.loading;
},
noIterations() {
return this.iterations.length === 0;
},
},
methods: {
handleOpen() {
this.editing = true;
this.$refs.dropdown.show();
},
handleClose() {
this.$refs.editableItem.collapse();
},
findIteration(iterationId) {
return this.iterations.find(({ id }) => id === iterationId);
},
setIteration(iterationId) {
this.editing = false;
if (iterationId === this.currentIteration?.id) return;
this.updating = true;
const selectedIteration = this.findIteration(iterationId);
this.selectedTitle = selectedIteration ? selectedIteration.title : this.$options.i18n.none;
this.$apollo
.mutate({
mutation: setIssueIterationMutation,
variables: {
projectPath: this.projectPathForActiveIssue,
iterationId,
iid: this.activeIssue.iid,
},
})
.then(({ data }) => {
if (data.issueSetIteration?.errors?.length) {
createFlash(data.issueSetIteration.errors[0]);
}
})
.catch(() => {
const { iterationSelectFail } = iterationSelectTextMap;
createFlash(iterationSelectFail);
})
.finally(() => {
this.updating = false;
this.searchTerm = '';
this.selectedTitle = null;
this.editing = false;
});
},
isIterationChecked(iterationId = undefined) {
return (
iterationId === this.currentIteration?.id || (!this.currentIteration?.id && !iterationId)
);
},
},
};
</script>
<template>
<board-editable-item
ref="editableItem"
:title="$options.i18n.iteration"
:loading="loading"
data-testid="iteration"
@open="handleOpen"
@close="handleClose"
>
<template #collapsed>
<gl-link v-if="showCurrentIteration" :href="iterationUrl"
><strong class="gl-text-gray-900">{{ iterationTitle }}</strong></gl-link
>
</template>
<gl-dropdown
ref="dropdown"
lazy
:header-text="$options.i18n.assignIteration"
:text="dropdownText"
:loading="loading"
class="gl-w-full"
@hide="handleClose"
>
<gl-search-box-by-type ref="search" v-model="searchTerm" />
<gl-dropdown-item
data-testid="no-iteration-item"
:is-check-item="true"
:is-checked="isIterationChecked($options.noIteration)"
@click="setIteration($options.noIteration)"
>
{{ $options.i18n.noIteration }}
</gl-dropdown-item>
<gl-dropdown-divider />
<gl-loading-icon
v-if="$apollo.queries.iterations.loading"
class="gl-py-4"
data-testid="loading-icon-dropdown"
/>
<template v-else>
<gl-dropdown-text v-if="noIterations">
{{ $options.i18n.noIterationsFound }}
</gl-dropdown-text>
<gl-dropdown-item
v-for="iterationItem in iterations"
:key="iterationItem.id"
:is-check-item="true"
:is-checked="isIterationChecked(iterationItem.id)"
data-testid="iteration-items"
@click="setIteration(iterationItem.id)"
>{{ iterationItem.title }}</gl-dropdown-item
>
</template>
</gl-dropdown>
</board-editable-item>
</template>
......@@ -9,7 +9,7 @@ import {
} from '@gitlab/ui';
import { __ } from '~/locale';
import { iterationSelectTextMap, iterationDisplayState } from '../constants';
import groupIterationsQuery from '../queries/group_iterations.query.graphql';
import groupIterationsQuery from '../queries/iterations.query.graphql';
export default {
directives: {
......
import { IssuableType } from '~/issue_show/constants';
import { s__, __ } from '~/locale';
import groupIterationsQuery from './queries/group_iterations.query.graphql';
import projectIssueIterationMutation from './queries/project_issue_iteration.mutation.graphql';
import projectIssueIterationQuery from './queries/project_issue_iteration.query.graphql';
export const healthStatus = {
ON_TRACK: 'onTrack',
......@@ -59,3 +63,16 @@ export const CVE_ID_REQUEST_SIDEBAR_I18N = {
),
learnMore: __('Learn more'),
};
export const issuableIterationQueries = {
[IssuableType.Issue]: {
query: projectIssueIterationQuery,
mutation: projectIssueIterationMutation,
},
};
export const iterationsQueries = {
[IssuableType.Issue]: {
query: groupIterationsQuery,
},
};
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { IssuableType } from '~/issue_show/constants';
import { parseBoolean } from '~/lib/utils/common_utils';
import { store } from '~/notes/stores';
import { apolloProvider } from '~/sidebar/graphql';
import * as CEMountSidebar from '~/sidebar/mount_sidebar';
import CveIdRequest from './components/cve_id_request/cve_id_request_sidebar.vue';
import IterationSelect from './components/iteration_select.vue';
import SidebarItemEpicsSelect from './components/sidebar_item_epics_select.vue';
import SidebarIterationWidget from './components/sidebar_iteration_widget.vue';
import SidebarStatus from './components/status/sidebar_status.vue';
import SidebarWeight from './components/weight/sidebar_weight.vue';
import SidebarStore from './stores/sidebar_store';
......@@ -106,21 +107,26 @@ function mountIterationSelect() {
if (!el) {
return false;
}
const { groupPath, canEdit, projectPath, issueIid } = el.dataset;
return new Vue({
el,
apolloProvider,
components: {
IterationSelect,
SidebarIterationWidget,
},
provide: {
canUpdate: parseBoolean(canEdit),
isClassicSidebar: true,
},
render: (createElement) =>
createElement('iteration-select', {
createElement('sidebar-iteration-widget', {
props: {
groupPath,
canEdit: parseBoolean(canEdit),
projectPath,
issueIid,
iterationsWorkspacePath: groupPath,
workspacePath: projectPath,
iid: issueIid,
issuableType: IssuableType.Issue,
},
}),
});
......
query groupIterations($fullPath: ID!, $title: String, $state: IterationState) {
group(fullPath: $fullPath) {
#import "./iteration.fragment.graphql"
query issueIterations($fullPath: ID!, $title: String, $state: IterationState) {
workspace: group(fullPath: $fullPath) {
__typename
iterations(title: $title, state: $state) {
nodes {
id
title
...IterationFragment
state
webUrl
}
}
}
......
fragment IterationFragment on Iteration {
id
title
webUrl
}
#import "./iteration.fragment.graphql"
query issueIterations($fullPath: ID!, $title: String, $state: IterationState) {
group(fullPath: $fullPath) {
iterations(title: $title, state: $state) {
nodes {
...IterationFragment
state
}
}
}
}
mutation projectIssueIterationMutation($fullPath: ID!, $iid: String!, $iterationId: ID) {
issuableSetIteration: issueSetIteration(
input: { projectPath: $fullPath, iid: $iid, iterationId: $iterationId }
) {
__typename
errors
issuable: issue {
__typename
id
iteration {
title
id
state
}
}
}
}
#import "./iteration.fragment.graphql"
query projectIssueIteration($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
__typename
issuable: issue(iid: $iid) {
__typename
id
iteration {
...IterationFragment
}
}
}
}
---
title: Add a separator between milestone and iteration dropdown in sidebars
merge_request: 54895
author:
type: other
......@@ -191,7 +191,9 @@ RSpec.describe 'Issue Sidebar' do
end
def find_and_click_edit_iteration
page.find('[data-testid="iteration-edit-link"]').click
page.find('[data-testid="iteration-edit-link"] [data-testid="edit-button"]').click
wait_for_all_requests
end
def select_iteration(iteration_name)
......
......@@ -2,15 +2,15 @@ import { GlDrawer } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import BoardContentSidebar from 'ee_component/boards/components/board_content_sidebar.vue';
import BoardSidebarIterationSelect from 'ee_component/boards/components/sidebar/board_sidebar_iteration_select.vue';
import SidebarIterationWidget from 'ee_component/sidebar/components/sidebar_iteration_widget';
import { stubComponent } from 'helpers/stub_component';
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue';
import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue';
import { ISSUABLE } from '~/boards/constants';
import { mockIssue } from '../mock_data';
import { ISSUABLE, issuableTypes } from '~/boards/constants';
import { mockIssue, mockIssueGroupPath, mockIssueProjectPath } from '../mock_data';
describe('ee/BoardContentSidebar', () => {
let wrapper;
......@@ -22,9 +22,12 @@ describe('ee/BoardContentSidebar', () => {
sidebarType: ISSUABLE,
issues: { [mockIssue.id]: mockIssue },
activeId: mockIssue.id,
issuableType: issuableTypes.issue,
},
getters: {
activeIssue: () => mockIssue,
projectPathForActiveIssue: () => mockIssueProjectPath,
groupPathForActiveIssue: () => mockIssueGroupPath,
isSidebarOpen: () => true,
...mockGetters,
},
......@@ -102,8 +105,8 @@ describe('ee/BoardContentSidebar', () => {
expect(wrapper.find(BoardSidebarMilestoneSelect).exists()).toBe(true);
});
it('renders BoardSidebarIterationSelect', () => {
expect(wrapper.find(BoardSidebarIterationSelect).exists()).toBe(true);
it('renders SidebarIterationWidget', () => {
expect(wrapper.find(SidebarIterationWidget).exists()).toBe(true);
});
describe('when we emit close', () => {
......
......@@ -134,16 +134,19 @@ export const rawIssue = {
},
};
export const mockIssueGroupPath = 'gitlab-org';
export const mockIssueProjectPath = `${mockIssueGroupPath}/gitlab-test`;
export const mockIssue = {
id: '436',
iid: '27',
title: 'Issue 1',
referencePath: '#27',
referencePath: `${mockIssueProjectPath}#27`,
dueDate: null,
timeEstimate: 0,
weight: null,
confidential: false,
path: '/gitlab-org/gitlab-test/-/issues/27',
path: `/${mockIssueProjectPath}/-/issues/27`,
assignees,
labels,
epic: {
......
......@@ -3,7 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import IterationDropdown from 'ee/sidebar/components/iteration_dropdown.vue';
import { iterationSelectTextMap } from 'ee/sidebar/constants';
import groupIterationsQuery from 'ee/sidebar/queries/group_iterations.query.graphql';
import groupIterationsQuery from 'ee/sidebar/queries/iterations.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
const localVue = createLocalVue();
......
......@@ -7,13 +7,6 @@ export const mockIssue = {
groupPath: mockGroupPath,
};
// This mock issue has a different format b/c
// it is used in board_sidebar_iteration_select_spec.js (swimlane sidebar)
export const mockIssue2 = {
referencePath: `${mockProjectPath}#1`,
iid: '1',
};
export const mockIssueId = 'gid://gitlab/Issue/1';
export const mockIteration1 = {
......@@ -32,9 +25,9 @@ export const mockIteration2 = {
state: 'opened',
};
export const mockIterationsResponse = {
export const mockGroupIterationsResponse = {
data: {
group: {
workspace: {
iterations: {
nodes: [mockIteration1, mockIteration2],
},
......@@ -44,9 +37,9 @@ export const mockIterationsResponse = {
},
};
export const emptyIterationsResponse = {
export const emptyGroupIterationsResponse = {
data: {
group: {
workspace: {
iterations: {
nodes: [],
},
......@@ -58,8 +51,8 @@ export const emptyIterationsResponse = {
export const noCurrentIterationResponse = {
data: {
project: {
issue: { id: mockIssueId, iteration: null, __typename: 'Issue' },
workspace: {
issuable: { id: mockIssueId, iteration: null, __typename: 'Issue' },
__typename: 'Project',
},
},
......@@ -67,9 +60,9 @@ export const noCurrentIterationResponse = {
export const mockMutationResponse = {
data: {
issueSetIteration: {
issuableSetIteration: {
errors: [],
issue: {
issuable: {
id: mockIssueId,
iteration: {
id: 'gid://gitlab/Iteration/2',
......
......@@ -12,8 +12,11 @@ module QA
super
base.class_eval do
view 'ee/app/assets/javascripts/sidebar/components/iteration_select.vue' do
element :edit_iteration_link
view 'app/assets/javascripts/sidebar/components/sidebar_editable_item.vue' do
element :edit_link
end
view 'ee/app/assets/javascripts/sidebar/components/sidebar_iteration_widget.vue' do
element :iteration_container
element :iteration_link
end
......@@ -29,8 +32,8 @@ module QA
end
def assign_iteration(iteration)
click_element(:edit_iteration_link)
within_element(:iteration_container) do
click_element(:edit_link)
click_on("#{iteration.title}")
end
......
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