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 = {
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 {
title: String!
}
type UpdateWorkItemInput {
id: ID!
title: String
}
type CreateWorkItemPayload {
workItem: WorkItem!
}
type UpdateWorkItemPayload {
workItem: WorkItem!
}
extend type Query {
workItem(id: ID!): WorkItem!
}
extend type Mutation {
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 @@
import { GlButton, GlAlert } from '@gitlab/ui';
import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql';
import ItemTitle from '../components/item_title.vue';
export default {
components: {
GlButton,
GlAlert,
ItemTitle,
},
data() {
return {
......@@ -37,6 +40,9 @@ export default {
this.error = true;
}
},
handleTitleInput(title) {
this.title = title;
},
},
};
</script>
......@@ -46,15 +52,7 @@ export default {
<gl-alert v-if="error" variant="danger" @dismiss="error = false">{{
__('Something went wrong when creating a work item. Please try again')
}}</gl-alert>
<label for="title" class="gl-sr-only">{{ __('Title') }}</label>
<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…')"
/>
<item-title data-testid="title-input" @title-input="handleTitleInput" />
<div class="gl-bg-gray-10 gl-py-5 gl-px-6">
<gl-button
variant="confirm"
......
<script>
import { GlAlert } from '@gitlab/ui';
import workItemQuery from '../graphql/work_item.query.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import { widgetTypes } from '../constants';
import ItemTitle from '../components/item_title.vue';
export default {
components: {
ItemTitle,
GlAlert,
},
props: {
id: {
type: String,
......@@ -12,6 +20,7 @@ export default {
data() {
return {
workItem: null,
error: false,
};
},
apollo: {
......@@ -29,20 +38,39 @@ export default {
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>
<template>
<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 -->
<div>
<h2
<item-title
v-if="titleWidgetData"
class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5"
:initial-title="titleWidgetData.contentText"
data-testid="title"
>
{{ titleWidgetData.contentText }}
</h2>
@title-changed="updateWorkItem"
/>
</div>
</section>
</template>
......@@ -479,6 +479,13 @@ img.emoji {
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. 🚨
See https://gitlab.com/gitlab-org/gitlab/issues/36857 for more details.
......
......@@ -2036,7 +2036,7 @@ msgstr ""
msgid "Add a task list"
msgstr ""
msgid "Add a title"
msgid "Add a title..."
msgstr ""
msgid "Add a to do"
......@@ -32840,6 +32840,9 @@ msgstr ""
msgid "Something went wrong while updating assignees"
msgstr ""
msgid "Something went wrong while updating work item. Please try again"
msgstr ""
msgid "Something went wrong while updating your list settings"
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 = {
},
},
};
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';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
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';
Vue.use(VueApollo);
......@@ -14,9 +15,9 @@ describe('Create work item component', () => {
let fakeApollo;
const findAlert = () => wrapper.findComponent(GlAlert);
const findTitleInput = () => wrapper.findComponent(ItemTitle);
const findCreateButton = () => wrapper.find('[data-testid="create-button"]');
const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]');
const findTitleInput = () => wrapper.find('[data-testid="title-input"]');
const createComponent = ({ data = {} } = {}) => {
fakeApollo = createMockApollo([], resolvers);
......@@ -70,9 +71,10 @@ describe('Create work item component', () => {
});
describe('when title input field has a text', () => {
beforeEach(() => {
beforeEach(async () => {
const mockTitle = 'Test title';
createComponent();
findTitleInput().setValue('Test title');
await findTitleInput().vm.$emit('title-input', mockTitle);
});
it('renders a non-disabled Create button', () => {
......
......@@ -2,8 +2,12 @@ import Vue from 'vue';
import { shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
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 updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
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';
Vue.use(VueApollo);
......@@ -14,10 +18,10 @@ describe('Work items root component', () => {
let wrapper;
let fakeApollo;
const findTitle = () => wrapper.find('[data-testid="title"]');
const findTitle = () => wrapper.findComponent(ItemTitle);
const createComponent = ({ queryResponse = workItemQueryResponse } = {}) => {
fakeApollo = createMockApollo();
fakeApollo = createMockApollo([], resolvers);
fakeApollo.clients.defaultClient.cache.writeQuery({
query: workItemQuery,
variables: {
......@@ -43,7 +47,28 @@ describe('Work items root component', () => {
createComponent();
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', () => {
......
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