Commit 55fa10f8 authored by Kushal Pandya's avatar Kushal Pandya Committed by Natalia Tepluhina

Add support for editing feature title

parent 6ba31f27
<script>
import { escape } from 'lodash';
import { __ } from '~/locale';
export default {
props: {
initialTitle: {
type: String,
required: false,
default: '',
},
placeholder: {
type: String,
required: false,
default: __('Add a title...'),
},
disabled: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
title: this.initialTitle,
};
},
methods: {
getSanitizedTitle(inputEl) {
const { innerText } = inputEl;
return escape(innerText);
},
handleBlur({ target }) {
this.$emit('title-changed', this.getSanitizedTitle(target));
},
handleInput({ target }) {
this.$emit('title-input', this.getSanitizedTitle(target));
},
handleSubmit() {
this.$refs.titleEl.blur();
},
},
};
</script>
<template>
<h2
class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5 gl-display-inline-block"
:class="{ 'gl-cursor-not-allowed': disabled }"
data-testid="title"
aria-labelledby="item-title"
>
<span
id="item-title"
ref="titleEl"
role="textbox"
:aria-label="__('Title')"
:data-placeholder="placeholder"
:contenteditable="!disabled"
class="gl-pseudo-placeholder"
@blur="handleBlur"
@keyup="handleInput"
@keydown.enter.exact="handleSubmit"
@keydown.ctrl.u.prevent
@keydown.meta.u.prevent
@keydown.ctrl.b.prevent
@keydown.meta.b.prevent
>{{ title }}</span
>
</h2>
</template>
...@@ -29,5 +29,30 @@ export const resolvers = { ...@@ -29,5 +29,30 @@ export const resolvers = {
workItem, workItem,
}; };
}, },
updateWorkItem(_, { input }, { cache }) {
const workItemTitle = {
__typename: 'TitleWidget',
type: 'TITLE',
enabled: true,
contentText: input.title,
};
const workItem = {
__typename: 'WorkItem',
type: 'FEATURE',
id: input.id,
widgets: {
__typename: 'WorkItemWidgetConnection',
nodes: [workItemTitle],
},
};
cache.writeQuery({ query: workItemQuery, variables: { id: input.id }, data: { workItem } });
return {
__typename: 'UpdateWorkItemPayload',
workItem,
};
},
}, },
}; };
...@@ -37,14 +37,24 @@ type CreateWorkItemInput { ...@@ -37,14 +37,24 @@ type CreateWorkItemInput {
title: String! title: String!
} }
type UpdateWorkItemInput {
id: ID!
title: String
}
type CreateWorkItemPayload { type CreateWorkItemPayload {
workItem: WorkItem! workItem: WorkItem!
} }
type UpdateWorkItemPayload {
workItem: WorkItem!
}
extend type Query { extend type Query {
workItem(id: ID!): WorkItem! workItem(id: ID!): WorkItem!
} }
extend type Mutation { extend type Mutation {
createWorkItem(input: CreateWorkItemInput!): CreateWorkItemPayload! createWorkItem(input: CreateWorkItemInput!): CreateWorkItemPayload!
updateWorkItem(input: UpdateWorkItemInput!): UpdateWorkItemPayload!
} }
#import './widget.fragment.graphql'
mutation updateWorkItem($input: UpdateWorkItemInput) {
updateWorkItem(input: $input) @client {
workItem {
id
type
widgets {
nodes {
...WidgetBase
... on TitleWidget {
contentText
}
}
}
}
}
}
...@@ -2,10 +2,13 @@ ...@@ -2,10 +2,13 @@
import { GlButton, GlAlert } from '@gitlab/ui'; import { GlButton, GlAlert } from '@gitlab/ui';
import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql'; import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql';
import ItemTitle from '../components/item_title.vue';
export default { export default {
components: { components: {
GlButton, GlButton,
GlAlert, GlAlert,
ItemTitle,
}, },
data() { data() {
return { return {
...@@ -37,6 +40,9 @@ export default { ...@@ -37,6 +40,9 @@ export default {
this.error = true; this.error = true;
} }
}, },
handleTitleInput(title) {
this.title = title;
},
}, },
}; };
</script> </script>
...@@ -46,15 +52,7 @@ export default { ...@@ -46,15 +52,7 @@ export default {
<gl-alert v-if="error" variant="danger" @dismiss="error = false">{{ <gl-alert v-if="error" variant="danger" @dismiss="error = false">{{
__('Something went wrong when creating a work item. Please try again') __('Something went wrong when creating a work item. Please try again')
}}</gl-alert> }}</gl-alert>
<label for="title" class="gl-sr-only">{{ __('Title') }}</label> <item-title data-testid="title-input" @title-input="handleTitleInput" />
<input
id="title"
v-model.trim="title"
type="text"
class="gl-font-size-h-display gl-font-weight-bold gl-my-5 gl-border-none gl-w-full gl-pl-2"
data-testid="title-input"
:placeholder="__('Add a title…')"
/>
<div class="gl-bg-gray-10 gl-py-5 gl-px-6"> <div class="gl-bg-gray-10 gl-py-5 gl-px-6">
<gl-button <gl-button
variant="confirm" variant="confirm"
......
<script> <script>
import { GlAlert } from '@gitlab/ui';
import workItemQuery from '../graphql/work_item.query.graphql'; import workItemQuery from '../graphql/work_item.query.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import { widgetTypes } from '../constants'; import { widgetTypes } from '../constants';
import ItemTitle from '../components/item_title.vue';
export default { export default {
components: {
ItemTitle,
GlAlert,
},
props: { props: {
id: { id: {
type: String, type: String,
...@@ -12,6 +20,7 @@ export default { ...@@ -12,6 +20,7 @@ export default {
data() { data() {
return { return {
workItem: null, workItem: null,
error: false,
}; };
}, },
apollo: { apollo: {
...@@ -29,20 +38,39 @@ export default { ...@@ -29,20 +38,39 @@ export default {
return this.workItem?.widgets?.nodes?.find((widget) => widget.type === widgetTypes.title); return this.workItem?.widgets?.nodes?.find((widget) => widget.type === widgetTypes.title);
}, },
}, },
methods: {
async updateWorkItem(title) {
try {
await this.$apollo.mutate({
mutation: updateWorkItemMutation,
variables: {
input: {
id: this.id,
title,
},
},
});
} catch {
this.error = true;
}
},
},
}; };
</script> </script>
<template> <template>
<section> <section>
<gl-alert v-if="error" variant="danger" @dismiss="error = false">{{
__('Something went wrong while updating work item. Please try again')
}}</gl-alert>
<!-- Title widget placeholder --> <!-- Title widget placeholder -->
<div> <div>
<h2 <item-title
v-if="titleWidgetData" v-if="titleWidgetData"
class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5" :initial-title="titleWidgetData.contentText"
data-testid="title" data-testid="title"
> @title-changed="updateWorkItem"
{{ titleWidgetData.contentText }} />
</h2>
</div> </div>
</section> </section>
</template> </template>
...@@ -479,6 +479,13 @@ img.emoji { ...@@ -479,6 +479,13 @@ img.emoji {
border-top: 1px solid $border-color; border-top: 1px solid $border-color;
} }
.gl-pseudo-placeholder:empty::before {
content: attr(data-placeholder);
font-weight: $gl-font-weight-normal;
color: $gl-text-color-secondary;
cursor: text;
}
/** /**
🚨 Do not use these classes — they clash with the Gitlab UI design system and will be removed. 🚨 🚨 Do not use these classes — they clash with the Gitlab UI design system and will be removed. 🚨
See https://gitlab.com/gitlab-org/gitlab/issues/36857 for more details. See https://gitlab.com/gitlab-org/gitlab/issues/36857 for more details.
......
...@@ -2036,7 +2036,7 @@ msgstr "" ...@@ -2036,7 +2036,7 @@ msgstr ""
msgid "Add a task list" msgid "Add a task list"
msgstr "" msgstr ""
msgid "Add a title" msgid "Add a title..."
msgstr "" msgstr ""
msgid "Add a to do" msgid "Add a to do"
...@@ -32840,6 +32840,9 @@ msgstr "" ...@@ -32840,6 +32840,9 @@ msgstr ""
msgid "Something went wrong while updating assignees" msgid "Something went wrong while updating assignees"
msgstr "" msgstr ""
msgid "Something went wrong while updating work item. Please try again"
msgstr ""
msgid "Something went wrong while updating your list settings" msgid "Something went wrong while updating your list settings"
msgstr "" msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import { escape } from 'lodash';
import ItemTitle from '~/work_items/components/item_title.vue';
jest.mock('lodash/escape', () => jest.fn((fn) => fn));
const createComponent = ({ initialTitle = 'Sample title', disabled = false } = {}) =>
shallowMount(ItemTitle, {
propsData: {
initialTitle,
disabled,
},
});
describe('ItemTitle', () => {
let wrapper;
const mockUpdatedTitle = 'Updated title';
const findInputEl = () => wrapper.find('span#item-title');
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders title contents', () => {
expect(findInputEl().attributes()).toMatchObject({
'data-placeholder': 'Add a title...',
contenteditable: 'true',
});
expect(findInputEl().text()).toBe('Sample title');
});
it('renders title contents with editing disabled', () => {
wrapper = createComponent({
disabled: true,
});
expect(wrapper.classes()).toContain('gl-cursor-not-allowed');
expect(findInputEl().attributes('contenteditable')).toBe('false');
});
it.each`
eventName | sourceEvent
${'title-changed'} | ${'blur'}
${'title-input'} | ${'keyup'}
`('emits "$eventName" event on input $sourceEvent', async ({ eventName, sourceEvent }) => {
findInputEl().element.innerText = mockUpdatedTitle;
await findInputEl().trigger(sourceEvent);
expect(wrapper.emitted(eventName)).toBeTruthy();
expect(escape).toHaveBeenCalledWith(mockUpdatedTitle);
});
});
...@@ -15,3 +15,22 @@ export const workItemQueryResponse = { ...@@ -15,3 +15,22 @@ export const workItemQueryResponse = {
}, },
}, },
}; };
export const updateWorkItemMutationResponse = {
__typename: 'UpdateWorkItemPayload',
workItem: {
__typename: 'WorkItem',
id: '1',
widgets: {
__typename: 'WorkItemWidgetConnection',
nodes: [
{
__typename: 'TitleWidget',
type: 'TITLE',
enabled: true,
contentText: 'Updated title',
},
],
},
},
};
...@@ -5,6 +5,7 @@ import { shallowMount } from '@vue/test-utils'; ...@@ -5,6 +5,7 @@ import { shallowMount } from '@vue/test-utils';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; import CreateWorkItem from '~/work_items/pages/create_work_item.vue';
import ItemTitle from '~/work_items/components/item_title.vue';
import { resolvers } from '~/work_items/graphql/resolvers'; import { resolvers } from '~/work_items/graphql/resolvers';
Vue.use(VueApollo); Vue.use(VueApollo);
...@@ -14,9 +15,9 @@ describe('Create work item component', () => { ...@@ -14,9 +15,9 @@ describe('Create work item component', () => {
let fakeApollo; let fakeApollo;
const findAlert = () => wrapper.findComponent(GlAlert); const findAlert = () => wrapper.findComponent(GlAlert);
const findTitleInput = () => wrapper.findComponent(ItemTitle);
const findCreateButton = () => wrapper.find('[data-testid="create-button"]'); const findCreateButton = () => wrapper.find('[data-testid="create-button"]');
const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]'); const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]');
const findTitleInput = () => wrapper.find('[data-testid="title-input"]');
const createComponent = ({ data = {} } = {}) => { const createComponent = ({ data = {} } = {}) => {
fakeApollo = createMockApollo([], resolvers); fakeApollo = createMockApollo([], resolvers);
...@@ -70,9 +71,10 @@ describe('Create work item component', () => { ...@@ -70,9 +71,10 @@ describe('Create work item component', () => {
}); });
describe('when title input field has a text', () => { describe('when title input field has a text', () => {
beforeEach(() => { beforeEach(async () => {
const mockTitle = 'Test title';
createComponent(); createComponent();
findTitleInput().setValue('Test title'); await findTitleInput().vm.$emit('title-input', mockTitle);
}); });
it('renders a non-disabled Create button', () => { it('renders a non-disabled Create button', () => {
......
...@@ -2,8 +2,12 @@ import Vue from 'vue'; ...@@ -2,8 +2,12 @@ import Vue from 'vue';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import WorkItemsRoot from '~/work_items/pages/work_item_root.vue'; import WorkItemsRoot from '~/work_items/pages/work_item_root.vue';
import ItemTitle from '~/work_items/components/item_title.vue';
import { resolvers } from '~/work_items/graphql/resolvers';
import { workItemQueryResponse } from '../mock_data'; import { workItemQueryResponse } from '../mock_data';
Vue.use(VueApollo); Vue.use(VueApollo);
...@@ -14,10 +18,10 @@ describe('Work items root component', () => { ...@@ -14,10 +18,10 @@ describe('Work items root component', () => {
let wrapper; let wrapper;
let fakeApollo; let fakeApollo;
const findTitle = () => wrapper.find('[data-testid="title"]'); const findTitle = () => wrapper.findComponent(ItemTitle);
const createComponent = ({ queryResponse = workItemQueryResponse } = {}) => { const createComponent = ({ queryResponse = workItemQueryResponse } = {}) => {
fakeApollo = createMockApollo(); fakeApollo = createMockApollo([], resolvers);
fakeApollo.clients.defaultClient.cache.writeQuery({ fakeApollo.clients.defaultClient.cache.writeQuery({
query: workItemQuery, query: workItemQuery,
variables: { variables: {
...@@ -43,7 +47,28 @@ describe('Work items root component', () => { ...@@ -43,7 +47,28 @@ describe('Work items root component', () => {
createComponent(); createComponent();
expect(findTitle().exists()).toBe(true); expect(findTitle().exists()).toBe(true);
expect(findTitle().text()).toBe('Test'); expect(findTitle().props('initialTitle')).toBe('Test');
});
it('updates the title when it is edited', async () => {
createComponent();
jest.spyOn(wrapper.vm.$apollo, 'mutate');
const mockUpdatedTitle = 'Updated title';
await findTitle().vm.$emit('title-changed', mockUpdatedTitle);
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: updateWorkItemMutation,
variables: {
input: {
id: WORK_ITEM_ID,
title: mockUpdatedTitle,
},
},
});
await waitForPromises();
expect(findTitle().props('initialTitle')).toBe(mockUpdatedTitle);
}); });
it('does not render the title if title is not in the widgets list', () => { it('does not render the title if title is not in the widgets list', () => {
......
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