diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue new file mode 100644 index 0000000000000000000000000000000000000000..5e9e50a94f074285a828ad0d629f7f28cb093bc3 --- /dev/null +++ b/app/assets/javascripts/work_items/components/item_title.vue @@ -0,0 +1,71 @@ +<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> diff --git a/app/assets/javascripts/work_items/graphql/resolvers.js b/app/assets/javascripts/work_items/graphql/resolvers.js index bab0147f4b8db7664fbca18252c31e9de5b0243c..8005f334314cb10df0dfe51fc3c604b0850621bb 100644 --- a/app/assets/javascripts/work_items/graphql/resolvers.js +++ b/app/assets/javascripts/work_items/graphql/resolvers.js @@ -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, + }; + }, }, }; diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql index 2a9cd52c18ec86abb680d9e5b5b06e478d1000d1..dd7ea7c26cc2aa5cb2d33c6bd6936de977f2d923 100644 --- a/app/assets/javascripts/work_items/graphql/typedefs.graphql +++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql @@ -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! } diff --git a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..fc140954fbe429ca7c9fe00bd1d0af9ff3a6b828 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql @@ -0,0 +1,18 @@ +#import './widget.fragment.graphql' + +mutation updateWorkItem($input: UpdateWorkItemInput) { + updateWorkItem(input: $input) @client { + workItem { + id + type + widgets { + nodes { + ...WidgetBase + ... on TitleWidget { + contentText + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue index 190e50f903c2e80c604d03091554090d301945ca..43cbee019c1ca7bf8cab469040e384daffa493d3 100644 --- a/app/assets/javascripts/work_items/pages/create_work_item.vue +++ b/app/assets/javascripts/work_items/pages/create_work_item.vue @@ -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" diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue index 493ee0aba01a7a6d9583e306de302b7c3454a93b..479274baf3a0d1fe7571c3fd49af9e5899a47ec6 100644 --- a/app/assets/javascripts/work_items/pages/work_item_root.vue +++ b/app/assets/javascripts/work_items/pages/work_item_root.vue @@ -1,8 +1,16 @@ <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> diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 354d2737894b5558b3cf9eb23c5522409a911b79..36a0d3ca3caa309100c0dc3f82e2ba681da606ff 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -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. diff --git a/locale/gitlab.pot b/locale/gitlab.pot index d666d45d2379e48e097a9013bc442f0fce95bbaf..e2c192b421391e0688d09b4a1aedd8a08c528711 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 "" diff --git a/spec/frontend/work_items/components/item_title_spec.js b/spec/frontend/work_items/components/item_title_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..0f6e7091c592fd75b2f178c6d920a5e0cf1c807e --- /dev/null +++ b/spec/frontend/work_items/components/item_title_spec.js @@ -0,0 +1,56 @@ +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); + }); +}); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index efb4aa2feb2fdf629dc0698fd442e6438370fa7d..c8d46b5188818114b7470a4c2b15163079871250 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -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', + }, + ], + }, + }, +}; diff --git a/spec/frontend/work_items/pages/create_work_item_spec.js b/spec/frontend/work_items/pages/create_work_item_spec.js index 180f61f559f90cd346144038ae3717fb12994d8c..71e153d30c3707d48583c383470e71523e3c37e5 100644 --- a/spec/frontend/work_items/pages/create_work_item_spec.js +++ b/spec/frontend/work_items/pages/create_work_item_spec.js @@ -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', () => { diff --git a/spec/frontend/work_items/pages/work_item_root_spec.js b/spec/frontend/work_items/pages/work_item_root_spec.js index ea76e2628d312e27ae3725f60158d802fe712ff7..02795751f334cc13fa62baacdd9790d200723622 100644 --- a/spec/frontend/work_items/pages/work_item_root_spec.js +++ b/spec/frontend/work_items/pages/work_item_root_spec.js @@ -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', () => {