Commit 3cd8e1ff authored by Florie Guibert's avatar Florie Guibert

Epic boards sidebar - Edit epic title

Add ability to edit epic title from epic board sidebar
parent 09580cb6
......@@ -2,11 +2,11 @@
import { GlDrawer } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
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 BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue';
import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
import { ISSUABLE } from '~/boards/constants';
import { contentTop } from '~/lib/utils/common_utils';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
......@@ -16,7 +16,7 @@ export default {
headerHeight: `${contentTop()}px`,
components: {
GlDrawer,
BoardSidebarIssueTitle,
BoardSidebarTitle,
SidebarAssigneesWidget,
BoardSidebarTimeTracker,
BoardSidebarLabelsSelect,
......@@ -67,7 +67,7 @@ export default {
>
<template #header>{{ __('Issue details') }}</template>
<template #default>
<board-sidebar-issue-title />
<board-sidebar-title />
<sidebar-assignees-widget
:iid="activeBoardItem.iid"
:full-path="fullPath"
......
......@@ -27,12 +27,12 @@ export default {
};
},
computed: {
...mapGetters({ issue: 'activeBoardItem' }),
...mapGetters({ item: 'activeBoardItem' }),
pendingChangesStorageKey() {
return this.getPendingChangesKey(this.issue);
return this.getPendingChangesKey(this.item);
},
projectPath() {
const referencePath = this.issue.referencePath || '';
const referencePath = this.item.referencePath || '';
return referencePath.slice(0, referencePath.indexOf('#'));
},
validationState() {
......@@ -40,29 +40,29 @@ export default {
},
},
watch: {
issue: {
handler(updatedIssue, formerIssue) {
if (formerIssue?.title !== this.title) {
localStorage.setItem(this.getPendingChangesKey(formerIssue), this.title);
item: {
handler(updatedItem, formerItem) {
if (formerItem?.title !== this.title) {
localStorage.setItem(this.getPendingChangesKey(formerItem), this.title);
}
this.title = updatedIssue.title;
this.title = updatedItem.title;
this.setPendingState();
},
immediate: true,
},
},
methods: {
...mapActions(['setActiveIssueTitle']),
getPendingChangesKey(issue) {
if (!issue) {
...mapActions(['setActiveItemTitle']),
getPendingChangesKey(item) {
if (!item) {
return '';
}
return joinPaths(
window.location.pathname.slice(1),
String(issue.id),
'issue-title-pending-changes',
String(item.id),
'item-title-pending-changes',
);
},
async setPendingState() {
......@@ -78,7 +78,7 @@ export default {
}
},
cancel() {
this.title = this.issue.title;
this.title = this.item.title;
this.$refs.sidebarItem.collapse();
this.showChangesAlert = false;
localStorage.removeItem(this.pendingChangesStorageKey);
......@@ -86,24 +86,24 @@ export default {
async setTitle() {
this.$refs.sidebarItem.collapse();
if (!this.title || this.title === this.issue.title) {
if (!this.title || this.title === this.item.title) {
return;
}
try {
this.loading = true;
await this.setActiveIssueTitle({ title: this.title, projectPath: this.projectPath });
await this.setActiveItemTitle({ title: this.title, projectPath: this.projectPath });
localStorage.removeItem(this.pendingChangesStorageKey);
this.showChangesAlert = false;
} catch (e) {
this.title = this.issue.title;
this.title = this.item.title;
createFlash({ message: this.$options.i18n.updateTitleError });
} finally {
this.loading = false;
}
},
handleOffClick() {
if (this.title !== this.issue.title) {
if (this.title !== this.item.title) {
this.showChangesAlert = true;
localStorage.setItem(this.pendingChangesStorageKey, this.title);
} else {
......@@ -112,11 +112,11 @@ export default {
},
},
i18n: {
issueTitlePlaceholder: __('Issue title'),
titlePlaceholder: __('Title'),
submitButton: __('Save changes'),
cancelButton: __('Cancel'),
updateTitleError: __('An error occurred when updating the issue title'),
invalidFeedback: __('An issue title is required'),
updateTitleError: __('An error occurred when updating the title'),
invalidFeedback: __('A title is required'),
reviewYourChanges: __('Changes to the title have not been saved'),
},
};
......@@ -131,10 +131,10 @@ export default {
@off-click="handleOffClick"
>
<template #title>
<span class="gl-font-weight-bold" data-testid="issue-title">{{ issue.title }}</span>
<span class="gl-font-weight-bold" data-testid="item-title">{{ item.title }}</span>
</template>
<template #collapsed>
<span class="gl-text-gray-800">{{ issue.referencePath }}</span>
<span class="gl-text-gray-800">{{ item.referencePath }}</span>
</template>
<gl-alert v-if="showChangesAlert" variant="warning" class="gl-mb-5" :dismissible="false">
{{ $options.i18n.reviewYourChanges }}
......@@ -144,7 +144,7 @@ export default {
<gl-form-input
v-model="title"
v-autofocusonshow
:placeholder="$options.i18n.issueTitlePlaceholder"
:placeholder="$options.i18n.titlePlaceholder"
:state="validationState"
/>
</gl-form-group>
......
import { __ } from '~/locale';
import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql';
import boardBlockingIssuesQuery from './graphql/board_blocking_issues.query.graphql';
import issueSetTitleMutation from './graphql/issue_set_title.mutation.graphql';
export const issuableTypes = {
issue: 'issue',
......@@ -52,3 +54,12 @@ export const blockingIssuablesQueries = {
query: boardBlockingIssuesQuery,
},
};
export const titleQueries = {
[issuableTypes.issue]: {
mutation: issueSetTitleMutation,
},
[issuableTypes.epic]: {
mutation: updateEpicTitleMutation,
},
};
mutation issueSetTitle($input: UpdateIssueInput!) {
updateIssue(input: $input) {
updateIssuableTitle: updateIssue(input: $input) {
issue {
title
}
......
......@@ -8,6 +8,7 @@ import {
inactiveId,
flashAnimationDuration,
ISSUABLE,
titleQueries,
} from '~/boards/constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
......@@ -33,7 +34,6 @@ import issueSetDueDateMutation from '../graphql/issue_set_due_date.mutation.grap
import issueSetLabelsMutation from '../graphql/issue_set_labels.mutation.graphql';
import issueSetMilestoneMutation from '../graphql/issue_set_milestone.mutation.graphql';
import issueSetSubscriptionMutation from '../graphql/issue_set_subscription.mutation.graphql';
import issueSetTitleMutation from '../graphql/issue_set_title.mutation.graphql';
import listsIssuesQuery from '../graphql/lists_issues.query.graphql';
import * as types from './mutation_types';
......@@ -526,27 +526,31 @@ export default {
});
},
setActiveIssueTitle: async ({ commit, getters }, input) => {
const { activeBoardItem } = getters;
setActiveItemTitle: async ({ commit, getters, state }, input) => {
const { activeBoardItem, isEpicBoard } = getters;
const { fullPath, issuableType } = state;
const workspacePath = isEpicBoard
? { groupPath: fullPath }
: { projectPath: input.projectPath };
const { data } = await gqlClient.mutate({
mutation: issueSetTitleMutation,
mutation: titleQueries[issuableType].mutation,
variables: {
input: {
...workspacePath,
iid: String(activeBoardItem.iid),
projectPath: input.projectPath,
title: input.title,
},
},
});
if (data.updateIssue?.errors?.length > 0) {
throw new Error(data.updateIssue.errors);
if (data.updateIssuableTitle?.errors?.length > 0) {
throw new Error(data.updateIssuableTitle.errors);
}
commit(types.UPDATE_BOARD_ITEM_BY_ID, {
itemId: activeBoardItem.id,
prop: 'title',
value: data.updateIssue.issue.title,
value: data.updateIssuableTitle[issuableType].title,
});
},
......
mutation updateEpic($input: UpdateEpicInput!) {
updateIssuableTitle: updateEpic(input: $input) {
epic {
title
}
errors
}
}
......@@ -2,6 +2,7 @@
import { GlDrawer } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
import { ISSUABLE } from '~/boards/constants';
import { contentTop } from '~/lib/utils/common_utils';
......@@ -10,6 +11,7 @@ export default {
components: {
GlDrawer,
BoardSidebarLabelsSelect,
BoardSidebarTitle,
},
computed: {
...mapGetters(['isSidebarOpen', 'activeBoardItem']),
......@@ -39,6 +41,7 @@ export default {
>
<template #header>{{ __('Epic details') }}</template>
<template #default>
<board-sidebar-title data-testid="sidebar-title" />
<board-sidebar-labels-select class="labels" />
</template>
</gl-drawer>
......
......@@ -50,6 +50,37 @@ RSpec.describe 'Epic boards sidebar', :js do
expect(page).not_to have_selector('[data-testid="epic-boards-sidebar"]')
end
it 'shows epic details when sidebar is open', :aggregate_failures do
click_card(card)
page.within('[data-testid="epic-boards-sidebar"]') do
expect(page).to have_content(epic1.title)
expect(page).to have_content(epic1.to_reference)
end
end
context 'title' do
it 'edits epic title' do
click_card(card)
page.within('[data-testid="sidebar-title"]') do
click_button 'Edit'
wait_for_requests
find('input').set('Test title')
click_button 'Save changes'
wait_for_requests
expect(page).to have_content('Test title')
end
expect(card).to have_content('Test title')
end
end
context 'labels' do
it 'adds a single label' do
click_card(card)
......
......@@ -3353,10 +3353,10 @@ msgstr ""
msgid "An error occurred when updating the issue due date"
msgstr ""
msgid "An error occurred when updating the issue title"
msgid "An error occurred when updating the issue weight"
msgstr ""
msgid "An error occurred when updating the issue weight"
msgid "An error occurred when updating the title"
msgstr ""
msgid "An error occurred while acknowledging the notification. Refresh the page and try again."
......@@ -3686,9 +3686,6 @@ msgstr ""
msgid "An issue already exists"
msgstr ""
msgid "An issue title is required"
msgstr ""
msgid "An unauthenticated user"
msgstr ""
......@@ -17342,9 +17339,6 @@ msgstr ""
msgid "Issue published on status page."
msgstr ""
msgid "Issue title"
msgstr ""
msgid "Issue types"
msgstr ""
......
......@@ -4,10 +4,10 @@ import Vuex from 'vuex';
import { stubComponent } from 'helpers/stub_component';
import BoardContentSidebar from '~/boards/components/board_content_sidebar.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';
import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue';
import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue';
import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
import { ISSUABLE } from '~/boards/constants';
import { mockIssue, mockIssueGroupPath, mockIssueProjectPath } from '../mock_data';
......@@ -102,8 +102,8 @@ describe('BoardContentSidebar', () => {
expect(wrapper.find(BoardSidebarLabelsSelect).exists()).toBe(true);
});
it('renders BoardSidebarIssueTitle', () => {
expect(wrapper.find(BoardSidebarIssueTitle).exists()).toBe(true);
it('renders BoardSidebarTitle', () => {
expect(wrapper.find(BoardSidebarTitle).exists()).toBe(true);
});
it('renders BoardSidebarDueDate', () => {
......
import { GlAlert, GlFormInput, GlForm } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue';
import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
import { createStore } from '~/boards/stores';
import createFlash from '~/flash';
const TEST_TITLE = 'New issue title';
const TEST_TITLE = 'New item title';
const TEST_ISSUE_A = {
id: 'gid://gitlab/Issue/1',
iid: 8,
......@@ -21,7 +21,7 @@ const TEST_ISSUE_B = {
jest.mock('~/flash');
describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => {
describe('~/boards/components/sidebar/board_sidebar_title.vue', () => {
let wrapper;
let store;
......@@ -32,12 +32,12 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => {
wrapper = null;
});
const createWrapper = (issue = TEST_ISSUE_A) => {
const createWrapper = (item = TEST_ISSUE_A) => {
store = createStore();
store.state.boardItems = { [issue.id]: { ...issue } };
store.dispatch('setActiveId', { id: issue.id });
store.state.boardItems = { [item.id]: { ...item } };
store.dispatch('setActiveId', { id: item.id });
wrapper = shallowMount(BoardSidebarIssueTitle, {
wrapper = shallowMount(BoardSidebarTitle, {
store,
provide: {
canUpdate: true,
......@@ -53,7 +53,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => {
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 findTitle = () => wrapper.find('[data-testid="item-title"]');
const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
it('renders title and reference', () => {
......@@ -73,7 +73,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => {
beforeEach(async () => {
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveIssueTitle').mockImplementation(() => {
jest.spyOn(wrapper.vm, 'setActiveItemTitle').mockImplementation(() => {
store.state.boardItems[TEST_ISSUE_A.id].title = TEST_TITLE;
});
findFormInput().vm.$emit('input', TEST_TITLE);
......@@ -87,7 +87,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => {
});
it('commits change to the server', () => {
expect(wrapper.vm.setActiveIssueTitle).toHaveBeenCalledWith({
expect(wrapper.vm.setActiveItemTitle).toHaveBeenCalledWith({
title: TEST_TITLE,
projectPath: 'h/b',
});
......@@ -98,14 +98,14 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => {
beforeEach(async () => {
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveIssueTitle').mockImplementation(() => {});
jest.spyOn(wrapper.vm, 'setActiveItemTitle').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();
expect(wrapper.vm.setActiveItemTitle).not.toHaveBeenCalled();
});
});
......@@ -122,7 +122,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => {
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(
expect(localStorage.getItem(`${TEST_ISSUE_A.id}/item-title-pending-changes`)).toBe(
TEST_TITLE,
);
});
......@@ -130,7 +130,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => {
describe('when accessing the form with pending changes', () => {
beforeAll(() => {
localStorage.setItem(`${TEST_ISSUE_A.id}/issue-title-pending-changes`, TEST_TITLE);
localStorage.setItem(`${TEST_ISSUE_A.id}/item-title-pending-changes`, TEST_TITLE);
createWrapper();
});
......@@ -146,7 +146,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => {
beforeEach(async () => {
createWrapper(TEST_ISSUE_B);
jest.spyOn(wrapper.vm, 'setActiveIssueTitle').mockImplementation(() => {
jest.spyOn(wrapper.vm, 'setActiveItemTitle').mockImplementation(() => {
store.state.boardItems[TEST_ISSUE_B.id].title = TEST_TITLE;
});
findFormInput().vm.$emit('input', TEST_TITLE);
......@@ -155,7 +155,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => {
});
it('collapses sidebar and render former title', () => {
expect(wrapper.vm.setActiveIssueTitle).not.toHaveBeenCalled();
expect(wrapper.vm.setActiveItemTitle).not.toHaveBeenCalled();
expect(findCollapsed().isVisible()).toBe(true);
expect(findTitle().text()).toBe(TEST_ISSUE_B.title);
});
......@@ -165,7 +165,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => {
beforeEach(async () => {
createWrapper(TEST_ISSUE_B);
jest.spyOn(wrapper.vm, 'setActiveIssueTitle').mockImplementation(() => {
jest.spyOn(wrapper.vm, 'setActiveItemTitle').mockImplementation(() => {
throw new Error(['failed mutation']);
});
findFormInput().vm.$emit('input', 'Invalid title');
......@@ -173,7 +173,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => {
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders former issue title', () => {
it('collapses sidebar and renders former item title', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findTitle().text()).toContain(TEST_ISSUE_B.title);
expect(createFlash).toHaveBeenCalled();
......
......@@ -1223,9 +1223,13 @@ describe('setActiveIssueMilestone', () => {
});
});
describe('setActiveIssueTitle', () => {
const state = { boardItems: { [mockIssue.id]: mockIssue } };
const getters = { activeBoardItem: mockIssue };
describe('setActiveItemTitle', () => {
const state = {
boardItems: { [mockIssue.id]: mockIssue },
issuableType: 'issue',
fullPath: 'path/f',
};
const getters = { activeBoardItem: mockIssue, isEpicBoard: false };
const testTitle = 'Test Title';
const input = {
title: testTitle,
......@@ -1235,7 +1239,7 @@ describe('setActiveIssueTitle', () => {
it('should commit title after setting the issue', (done) => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
updateIssue: {
updateIssuableTitle: {
issue: {
title: testTitle,
},
......@@ -1251,7 +1255,7 @@ describe('setActiveIssueTitle', () => {
};
testAction(
actions.setActiveIssueTitle,
actions.setActiveItemTitle,
input,
{ ...state, ...getters },
[
......@@ -1270,7 +1274,7 @@ describe('setActiveIssueTitle', () => {
.spyOn(gqlClient, 'mutate')
.mockResolvedValue({ data: { updateIssue: { errors: ['failed mutation'] } } });
await expect(actions.setActiveIssueTitle({ getters }, input)).rejects.toThrow(Error);
await expect(actions.setActiveItemTitle({ getters }, input)).rejects.toThrow(Error);
});
});
......
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