Commit f4f206b5 authored by Axel Garcia's avatar Axel Garcia Committed by Enrique Alcántara

Edit issue title in swimlanes sidebar

This adds an inline form to update the active
issue title
parent 198349ee
......@@ -14,6 +14,16 @@ export default {
required: false,
default: false,
},
toggleHeader: {
type: Boolean,
required: false,
default: false,
},
handleOffClick: {
type: Boolean,
required: false,
default: true,
},
},
inject: ['canUpdate'],
data() {
......@@ -21,13 +31,25 @@ export default {
edit: false,
};
},
computed: {
showHeader() {
if (!this.toggleHeader) {
return true;
}
return !this.edit;
},
},
destroyed() {
window.removeEventListener('click', this.collapseWhenOffClick);
},
methods: {
collapseWhenOffClick({ target }) {
if (!this.$el.contains(target)) {
this.collapse();
this.$emit('off-click');
if (this.handleOffClick) {
this.collapse();
}
}
},
expand() {
......@@ -63,21 +85,26 @@ export default {
<template>
<div>
<div class="gl-display-flex gl-justify-content-space-between gl-mb-3">
<header
v-show="showHeader"
class="gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-mb-3"
>
<span class="gl-vertical-align-middle">
<span data-testid="title">{{ title }}</span>
<slot name="title">
<span data-testid="title">{{ title }}</span>
</slot>
<gl-loading-icon v-if="loading" inline class="gl-ml-2" />
</span>
<gl-button
v-if="canUpdate"
variant="link"
class="gl-text-gray-900! js-sidebar-dropdown-toggle"
class="gl-text-gray-900! gl-ml-5 js-sidebar-dropdown-toggle"
data-testid="edit-button"
@click="toggle"
>
{{ __('Edit') }}
</gl-button>
</div>
</header>
<div v-show="!edit" class="gl-text-gray-500" data-testid="collapsed-content">
<slot name="collapsed">{{ __('None') }}</slot>
</div>
......
<script>
import { mapGetters, mapActions } from 'vuex';
import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
import { joinPaths } from '~/lib/utils/url_utility';
import createFlash from '~/flash';
import { __ } from '~/locale';
export default {
components: {
GlForm,
GlAlert,
GlButton,
GlFormGroup,
GlFormInput,
BoardEditableItem,
},
directives: {
autofocusonshow,
},
data() {
return {
title: '',
loading: false,
showChangesAlert: false,
};
},
computed: {
...mapGetters({ issue: 'activeIssue' }),
pendingChangesStorageKey() {
return this.getPendingChangesKey(this.issue);
},
projectPath() {
const referencePath = this.issue.referencePath || '';
return referencePath.slice(0, referencePath.indexOf('#'));
},
validationState() {
return Boolean(this.title);
},
},
watch: {
issue: {
handler(updatedIssue, formerIssue) {
if (formerIssue?.title !== this.title) {
localStorage.setItem(this.getPendingChangesKey(formerIssue), this.title);
}
this.title = updatedIssue.title;
this.setPendingState();
},
immediate: true,
},
},
methods: {
...mapActions(['setActiveIssueTitle']),
getPendingChangesKey(issue) {
if (!issue) {
return '';
}
return joinPaths(
window.location.pathname.slice(1),
String(issue.id),
'issue-title-pending-changes',
);
},
async setPendingState() {
const pendingChanges = localStorage.getItem(this.pendingChangesStorageKey);
if (pendingChanges) {
this.title = pendingChanges;
this.showChangesAlert = true;
await this.$nextTick();
this.$refs.sidebarItem.expand();
} else {
this.showChangesAlert = false;
}
},
cancel() {
this.title = this.issue.title;
this.$refs.sidebarItem.collapse();
this.showChangesAlert = false;
localStorage.removeItem(this.pendingChangesStorageKey);
},
async setTitle() {
this.$refs.sidebarItem.collapse();
if (!this.title || this.title === this.issue.title) {
return;
}
try {
this.loading = true;
await this.setActiveIssueTitle({ title: this.title, projectPath: this.projectPath });
localStorage.removeItem(this.pendingChangesStorageKey);
this.showChangesAlert = false;
} catch (e) {
this.title = this.issue.title;
createFlash({ message: this.$options.i18n.updateTitleError });
} finally {
this.loading = false;
}
},
handleOffClick() {
if (this.title !== this.issue.title) {
this.showChangesAlert = true;
localStorage.setItem(this.pendingChangesStorageKey, this.title);
} else {
this.$refs.sidebarItem.collapse();
}
},
},
i18n: {
issueTitlePlaceholder: __('Issue title'),
submitButton: __('Save changes'),
cancelButton: __('Cancel'),
updateTitleError: __('An error occurred when updating the issue title'),
invalidFeedback: __('An issue title is required'),
reviewYourChanges: __('Changes to the title have not been saved'),
},
};
</script>
<template>
<board-editable-item
ref="sidebarItem"
toggle-header
:loading="loading"
:handle-off-click="false"
@off-click="handleOffClick"
>
<template #title>
<span class="gl-font-weight-bold" data-testid="issue-title">{{ issue.title }}</span>
</template>
<template #collapsed>
<span class="gl-text-gray-800">{{ issue.referencePath }}</span>
</template>
<template>
<gl-alert v-if="showChangesAlert" variant="warning" class="gl-mb-5" :dismissible="false">
{{ $options.i18n.reviewYourChanges }}
</gl-alert>
<gl-form @submit.prevent="setTitle">
<gl-form-group :invalid-feedback="$options.i18n.invalidFeedback" :state="validationState">
<gl-form-input
v-model="title"
v-autofocusonshow
:placeholder="$options.i18n.issueTitlePlaceholder"
:state="validationState"
/>
</gl-form-group>
<div class="gl-display-flex gl-w-full gl-justify-content-space-between gl-mt-5">
<gl-button
variant="success"
size="small"
data-testid="submit-button"
:disabled="!title"
@click="setTitle"
>
{{ $options.i18n.submitButton }}
</gl-button>
<gl-button size="small" data-testid="cancel-button" @click="cancel">
{{ $options.i18n.cancelButton }}
</gl-button>
</div>
</gl-form>
</template>
</board-editable-item>
</template>
mutation issueSetTitle($input: UpdateIssueInput!) {
updateIssue(input: $input) {
issue {
title
}
errors
}
}
......@@ -27,6 +27,7 @@ import issueSetLabelsMutation from '../graphql/issue_set_labels.mutation.graphql
import issueSetDueDateMutation from '../graphql/issue_set_due_date.mutation.graphql';
import issueSetSubscriptionMutation from '../graphql/issue_set_subscription.mutation.graphql';
import issueSetMilestoneMutation from '../graphql/issue_set_milestone.mutation.graphql';
import issueSetTitleMutation from '../graphql/issue_set_title.mutation.graphql';
const notImplemented = () => {
/* eslint-disable-next-line @gitlab/require-i18n-strings */
......@@ -472,6 +473,30 @@ export default {
});
},
setActiveIssueTitle: async ({ commit, getters }, input) => {
const { activeIssue } = getters;
const { data } = await gqlClient.mutate({
mutation: issueSetTitleMutation,
variables: {
input: {
iid: String(activeIssue.iid),
projectPath: input.projectPath,
title: input.title,
},
},
});
if (data.updateIssue?.errors?.length > 0) {
throw new Error(data.updateIssue.errors);
}
commit(types.UPDATE_ISSUE_BY_ID, {
issueId: activeIssue.id,
prop: 'title',
value: data.updateIssue.issue.title,
});
},
fetchBacklog: () => {
notImplemented();
},
......
......@@ -321,7 +321,8 @@ As in other list types, click the trash icon to remove a list.
### Group issues in swimlanes **(PREMIUM)**
> Grouping by epic [introduced](https://gitlab.com/groups/gitlab-org/-/epics/3352) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.6.
> - Grouping by epic [introduced](https://gitlab.com/groups/gitlab-org/-/epics/3352) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.6.
> - Editing issue titles in the issue sidebar [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/232745) in GitLab 13.8.
With swimlanes you can visualize issues grouped by epic.
Your issue board keeps all the other features, but with a different visual organization of issues.
......@@ -337,6 +338,19 @@ To group issues by epic in an issue board:
![Epics Swimlanes](img/epics_swimlanes_v13.6.png)
To edit an issue without leaving this view, select the issue card (not its title), and a sidebar
appears on the right. There you can see and edit the issue's:
- Title
- Assignees
- Epic **PREMIUM**
- Milestone
- Time tracking value (view only)
- Due date
- Labels
- Weight
- Notifications setting
You can also [drag issues](#drag-issues-between-lists) to change their position and epic assignment:
- To reorder an issue, drag it to the new position within a list.
......
......@@ -3,13 +3,13 @@ import { mapState, mapActions, mapGetters } from 'vuex';
import { GlDrawer } from '@gitlab/ui';
import { ISSUABLE } from '~/boards/constants';
import { contentTop } from '~/lib/utils/common_utils';
import IssuableTitle from '~/boards/components/issuable_title.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import BoardSidebarEpicSelect from './sidebar/board_sidebar_epic_select.vue';
import BoardAssigneeDropdown from '~/boards/components/board_assignee_dropdown.vue';
import BoardSidebarTimeTracker from './sidebar/board_sidebar_time_tracker.vue';
import BoardSidebarWeightInput from './sidebar/board_sidebar_weight_input.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue';
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue';
import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue';
......@@ -18,7 +18,7 @@ export default {
headerHeight: `${contentTop()}px`,
components: {
GlDrawer,
IssuableTitle,
BoardSidebarIssueTitle,
BoardSidebarEpicSelect,
BoardAssigneeDropdown,
BoardSidebarTimeTracker,
......@@ -49,11 +49,10 @@ export default {
:header-height="$options.headerHeight"
@close="unsetActiveId"
>
<template #header>
<issuable-title :ref-path="activeIssue.referencePath" :title="activeIssue.title" />
</template>
<template #header>{{ __('Issue details') }}</template>
<template>
<board-sidebar-issue-title />
<board-assignee-dropdown />
<board-sidebar-epic-select />
<board-sidebar-milestone-select />
......
---
title: Edit issue title in swimlanes sidebar
merge_request: 46404
author:
type: added
......@@ -4,7 +4,11 @@ import BoardContentSidebar from 'ee_component/boards/components/board_content_si
import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import BoardAssigneeDropdown from '~/boards/components/board_assignee_dropdown.vue';
import IssuableTitle from '~/boards/components/issuable_title.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue';
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue';
import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue';
import { ISSUABLE } from '~/boards/constants';
import { createStore } from '~/boards/stores';
......@@ -16,7 +20,8 @@ describe('ee/BoardContentSidebar', () => {
wrapper = shallowMount(BoardContentSidebar, {
provide: {
canUpdate: true,
rootPath: '',
rootPath: '/',
groupId: '#',
},
store,
stubs: {
......@@ -58,14 +63,30 @@ describe('ee/BoardContentSidebar', () => {
expect(wrapper.find(GlDrawer).props('open')).toBe(true);
});
it('finds IssuableTitle', () => {
expect(wrapper.find(IssuableTitle).props('title')).toContain('One');
});
it('renders BoardAssigneeDropdown', () => {
expect(wrapper.find(BoardAssigneeDropdown).exists()).toBe(true);
});
it('renders BoardSidebarLabelsSelect', () => {
expect(wrapper.find(BoardSidebarLabelsSelect).exists()).toBe(true);
});
it('renders BoardSidebarIssueTitle', () => {
expect(wrapper.find(BoardSidebarIssueTitle).exists()).toBe(true);
});
it('renders BoardSidebarDueDate', () => {
expect(wrapper.find(BoardSidebarDueDate).exists()).toBe(true);
});
it('renders BoardSidebarSubscription', () => {
expect(wrapper.find(BoardSidebarSubscription).exists()).toBe(true);
});
it('renders BoardSidebarMilestoneSelect', () => {
expect(wrapper.find(BoardSidebarMilestoneSelect).exists()).toBe(true);
});
describe('when we emit close', () => {
it('hides GlDrawer', async () => {
expect(wrapper.find(GlDrawer).props('open')).toBe(true);
......
......@@ -3075,6 +3075,9 @@ msgstr ""
msgid "An error occurred when updating the issue due date"
msgstr ""
msgid "An error occurred when updating the issue title"
msgstr ""
msgid "An error occurred when updating the issue weight"
msgstr ""
......@@ -3381,6 +3384,9 @@ msgstr ""
msgid "An issue can be a bug, a todo or a feature request that needs to be discussed in a project. Besides, issues are searchable and filterable."
msgstr ""
msgid "An issue title is required"
msgstr ""
msgid "An unauthenticated user"
msgstr ""
......@@ -5272,6 +5278,9 @@ msgstr ""
msgid "Changes the title to \"%{title_param}\"."
msgstr ""
msgid "Changes to the title have not been saved"
msgstr ""
msgid "Changes won't take place until the index is %{link_start}recreated%{link_end}."
msgstr ""
......@@ -15567,6 +15576,9 @@ msgstr ""
msgid "Issue created from vulnerability %{vulnerability_link}"
msgstr ""
msgid "Issue details"
msgstr ""
msgid "Issue events"
msgstr ""
......@@ -15582,6 +15594,9 @@ msgstr ""
msgid "Issue published on status page."
msgstr ""
msgid "Issue title"
msgstr ""
msgid "Issue update failed"
msgstr ""
......
......@@ -33,6 +33,14 @@ describe('boards sidebar remove issue', () => {
expect(findTitle().text()).toBe(title);
});
it('renders provided title slot', () => {
const title = 'Sidebar item title on slot';
const slots = { title: `<strong>${title}</strong>` };
createComponent({ slots });
expect(wrapper.text()).toContain(title);
});
it('hides edit button, loader and expanded content by default', () => {
createComponent();
......@@ -74,9 +82,19 @@ describe('boards sidebar remove issue', () => {
return wrapper.vm.$nextTick().then(() => {
expect(findCollapsed().isVisible()).toBe(false);
expect(findExpanded().isVisible()).toBe(true);
expect(findExpanded().text()).toBe('Select item');
});
});
it('hides the header while editing if `toggleHeader` is true', async () => {
createComponent({ canUpdate: true, props: { toggleHeader: true } });
findEditButton().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(findEditButton().isVisible()).toBe(false);
expect(findTitle().isVisible()).toBe(false);
expect(findExpanded().isVisible()).toBe(true);
});
});
describe('collapsing an item by offclicking', () => {
......@@ -96,12 +114,13 @@ describe('boards sidebar remove issue', () => {
expect(findExpanded().isVisible()).toBe(false);
});
it('emits close event', async () => {
it('emits events', async () => {
document.body.click();
await wrapper.vm.$nextTick();
expect(wrapper.emitted().close.length).toBe(1);
expect(wrapper.emitted().close).toHaveLength(1);
expect(wrapper.emitted()['off-click']).toHaveLength(1);
});
});
......
import { shallowMount } from '@vue/test-utils';
import { GlAlert, GlFormInput, GlForm } from '@gitlab/ui';
import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import createFlash from '~/flash';
import { createStore } from '~/boards/stores';
const TEST_TITLE = 'New issue title';
const TEST_ISSUE_A = {
id: 'gid://gitlab/Issue/1',
iid: 8,
title: 'Issue 1',
referencePath: 'h/b#1',
};
const TEST_ISSUE_B = {
id: 'gid://gitlab/Issue/2',
iid: 9,
title: 'Issue 2',
referencePath: 'h/b#2',
};
jest.mock('~/flash');
describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => {
let wrapper;
let store;
afterEach(() => {
localStorage.clear();
wrapper.destroy();
store = null;
wrapper = null;
});
const createWrapper = (issue = TEST_ISSUE_A) => {
store = createStore();
store.state.issues = { [issue.id]: { ...issue } };
store.dispatch('setActiveId', { id: issue.id });
wrapper = shallowMount(BoardSidebarIssueTitle, {
store,
provide: {
canUpdate: true,
},
stubs: {
'board-editable-item': BoardEditableItem,
},
});
};
const findForm = () => wrapper.find(GlForm);
const findAlert = () => wrapper.find(GlAlert);
const findFormInput = () => wrapper.find(GlFormInput);
const findEditableItem = () => wrapper.find(BoardEditableItem);
const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]');
const findTitle = () => wrapper.find('[data-testid="issue-title"]');
const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
it('renders title and reference', () => {
createWrapper();
expect(findTitle().text()).toContain(TEST_ISSUE_A.title);
expect(findCollapsed().text()).toContain(TEST_ISSUE_A.referencePath);
});
it('does not render alert', () => {
createWrapper();
expect(findAlert().exists()).toBe(false);
});
describe('when new title is submitted', () => {
beforeEach(async () => {
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveIssueTitle').mockImplementation(() => {
store.state.issues[TEST_ISSUE_A.id].title = TEST_TITLE;
});
findFormInput().vm.$emit('input', TEST_TITLE);
findForm().vm.$emit('submit', { preventDefault: () => {} });
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders new title', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findTitle().text()).toContain(TEST_TITLE);
});
it('commits change to the server', () => {
expect(wrapper.vm.setActiveIssueTitle).toHaveBeenCalledWith({
title: TEST_TITLE,
projectPath: 'h/b',
});
});
});
describe('when submitting and invalid title', () => {
beforeEach(async () => {
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveIssueTitle').mockImplementation(() => {});
findFormInput().vm.$emit('input', '');
findForm().vm.$emit('submit', { preventDefault: () => {} });
await wrapper.vm.$nextTick();
});
it('commits change to the server', () => {
expect(wrapper.vm.setActiveIssueTitle).not.toHaveBeenCalled();
});
});
describe('when abandoning the form without saving', () => {
beforeEach(async () => {
createWrapper();
wrapper.vm.$refs.sidebarItem.expand();
findFormInput().vm.$emit('input', TEST_TITLE);
findEditableItem().vm.$emit('off-click');
await wrapper.vm.$nextTick();
});
it('does not collapses sidebar and shows alert', () => {
expect(findCollapsed().isVisible()).toBe(false);
expect(findAlert().exists()).toBe(true);
expect(localStorage.getItem(`${TEST_ISSUE_A.id}/issue-title-pending-changes`)).toBe(
TEST_TITLE,
);
});
});
describe('when accessing the form with pending changes', () => {
beforeAll(() => {
localStorage.setItem(`${TEST_ISSUE_A.id}/issue-title-pending-changes`, TEST_TITLE);
createWrapper();
});
it('sets title, expands item and shows alert', async () => {
expect(wrapper.vm.title).toBe(TEST_TITLE);
expect(findCollapsed().isVisible()).toBe(false);
expect(findAlert().exists()).toBe(true);
});
});
describe('when cancel button is clicked', () => {
beforeEach(async () => {
createWrapper(TEST_ISSUE_B);
jest.spyOn(wrapper.vm, 'setActiveIssueTitle').mockImplementation(() => {
store.state.issues[TEST_ISSUE_B.id].title = TEST_TITLE;
});
findFormInput().vm.$emit('input', TEST_TITLE);
findCancelButton().vm.$emit('click');
await wrapper.vm.$nextTick();
});
it('collapses sidebar and render former title', () => {
expect(wrapper.vm.setActiveIssueTitle).not.toHaveBeenCalled();
expect(findCollapsed().isVisible()).toBe(true);
expect(findTitle().text()).toBe(TEST_ISSUE_B.title);
});
});
describe('when the mutation fails', () => {
beforeEach(async () => {
createWrapper(TEST_ISSUE_B);
jest.spyOn(wrapper.vm, 'setActiveIssueTitle').mockImplementation(() => {
throw new Error(['failed mutation']);
});
findFormInput().vm.$emit('input', 'Invalid title');
findForm().vm.$emit('submit', { preventDefault: () => {} });
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders former issue title', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findTitle().text()).toContain(TEST_ISSUE_B.title);
expect(createFlash).toHaveBeenCalled();
});
});
});
......@@ -986,6 +986,57 @@ describe('setActiveIssueMilestone', () => {
});
});
describe('setActiveIssueTitle', () => {
const state = { issues: { [mockIssue.id]: mockIssue } };
const getters = { activeIssue: mockIssue };
const testTitle = 'Test Title';
const input = {
title: testTitle,
projectPath: 'h/b',
};
it('should commit title after setting the issue', (done) => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
updateIssue: {
issue: {
title: testTitle,
},
errors: [],
},
},
});
const payload = {
issueId: getters.activeIssue.id,
prop: 'title',
value: testTitle,
};
testAction(
actions.setActiveIssueTitle,
input,
{ ...state, ...getters },
[
{
type: types.UPDATE_ISSUE_BY_ID,
payload,
},
],
[],
done,
);
});
it('throws error if fails', async () => {
jest
.spyOn(gqlClient, 'mutate')
.mockResolvedValue({ data: { updateIssue: { errors: ['failed mutation'] } } });
await expect(actions.setActiveIssueTitle({ getters }, input)).rejects.toThrow(Error);
});
});
describe('fetchBacklog', () => {
expectNotImplemented(actions.fetchBacklog);
});
......
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