Commit 6d7bb5d2 authored by Coung Ngo's avatar Coung Ngo Committed by Natalia Tepluhina

Edit title in work item detail modal

Behind `work_items` feature flag, defaulted off
parent f1a210d0
......@@ -239,9 +239,6 @@ export default {
closeWorkItemDetailModal() {
this.workItemId = null;
},
handleWorkItemDetailModalError(message) {
createFlash({ message });
},
handleCreateTask(description) {
this.$emit('updateDescription', description);
this.closeCreateTaskModal();
......@@ -306,7 +303,6 @@ export default {
:visible="showWorkItemDetailModal"
:work-item-id="workItemId"
@close="closeWorkItemDetailModal"
@error="handleWorkItemDetailModalError"
/>
<template v-if="workItemsEnabled">
<gl-popover
......
......@@ -2,10 +2,7 @@
import { escape } from 'lodash';
import { __ } from '~/locale';
import { WI_TITLE_TRACK_LABEL } from '../constants';
export default {
WI_TITLE_TRACK_LABEL,
props: {
initialTitle: {
type: String,
......@@ -50,7 +47,6 @@ export default {
<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
......@@ -59,7 +55,6 @@ export default {
role="textbox"
:aria-label="__('Title')"
:data-placeholder="placeholder"
:data-track-label="$options.WI_TITLE_TRACK_LABEL"
:contenteditable="!disabled"
class="gl-pseudo-placeholder"
@blur="handleBlur"
......
<script>
import { GlModal, GlLoadingIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
import { GlAlert, GlModal } from '@gitlab/ui';
import { i18n } from '../constants';
import workItemQuery from '../graphql/work_item.query.graphql';
import ItemTitle from './item_title.vue';
import WorkItemTitle from './work_item_title.vue';
export default {
i18n,
components: {
GlAlert,
GlModal,
GlLoadingIcon,
ItemTitle,
WorkItemTitle,
},
props: {
visible: {
......@@ -23,6 +24,7 @@ export default {
},
data() {
return {
error: undefined,
workItem: {},
};
},
......@@ -34,23 +36,17 @@ export default {
id: this.workItemId,
};
},
update(data) {
return data.workItem;
},
skip() {
return !this.workItemId;
},
error() {
this.$emit(
'error',
s__('WorkItem|Something went wrong when fetching the work item. Please try again.'),
);
this.error = this.$options.i18n.fetchError;
},
},
},
computed: {
workItemTitle() {
return this.workItem?.title;
workItemType() {
return this.workItem.workItemType?.name;
},
},
};
......@@ -58,7 +54,16 @@ export default {
<template>
<gl-modal hide-footer modal-id="work-item-detail-modal" :visible="visible" @hide="$emit('close')">
<gl-loading-icon v-if="$apollo.queries.workItem.loading" size="md" />
<item-title v-else class="gl-m-0!" :initial-title="workItemTitle" />
<gl-alert v-if="error" variant="danger" @dismiss="error = false">
{{ error }}
</gl-alert>
<work-item-title
:loading="$apollo.queries.workItem.loading"
:work-item-id="workItem.id"
:work-item-title="workItem.title"
:work-item-type="workItemType"
@error="error = $event"
/>
</gl-modal>
</template>
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import Tracking from '~/tracking';
import { i18n } from '../constants';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import ItemTitle from './item_title.vue';
export default {
components: {
GlLoadingIcon,
ItemTitle,
},
mixins: [Tracking.mixin()],
props: {
loading: {
type: Boolean,
required: false,
default: false,
},
workItemId: {
type: String,
required: false,
default: '',
},
workItemTitle: {
type: String,
required: false,
default: '',
},
workItemType: {
type: String,
required: false,
default: '',
},
},
computed: {
tracking() {
return {
category: 'workItems:show',
label: 'item_title',
property: `type_${this.workItemType}`,
};
},
},
methods: {
async updateWorkItem(updatedTitle) {
if (updatedTitle === this.workItemTitle) {
return;
}
try {
await this.$apollo.mutate({
mutation: updateWorkItemMutation,
variables: {
input: {
id: this.workItemId,
title: updatedTitle,
},
},
});
this.track('updated_title');
} catch {
this.$emit('error', i18n.updateError);
}
},
},
};
</script>
<template>
<gl-loading-icon v-if="loading" class="gl-mt-3" size="md" />
<item-title v-else :initial-title="workItemTitle" @title-changed="updateWorkItem" />
</template>
import { s__ } from '~/locale';
export const i18n = {
fetchError: s__('WorkItem|Something went wrong when fetching the work item. Please try again.'),
updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'),
};
export const widgetTypes = {
title: 'TITLE',
};
export const WI_TITLE_TRACK_LABEL = 'item_title';
#import './widget.fragment.graphql'
#import "./work_item.fragment.graphql"
mutation createWorkItem($input: WorkItemCreateInput!) {
workItemCreate(input: $input) {
workItem {
id
title
workItemType {
id
}
widgets @client {
nodes {
...WidgetBase
}
}
...WorkItem
}
}
}
......@@ -23,12 +23,16 @@ export function createApolloProvider() {
id: 'gid://gitlab/WorkItem/1',
},
data: {
localWorkItem: {
__typename: 'LocalWorkItem',
workItem: {
__typename: 'WorkItem',
id: 'gid://gitlab/WorkItem/1',
type: 'FEATURE',
// eslint-disable-next-line @gitlab/require-i18n-strings
title: 'Test Work Item',
workItemType: {
__typename: 'WorkItemType',
id: 'work-item-type-1',
name: 'Type', // eslint-disable-line @gitlab/require-i18n-strings
},
widgets: {
__typename: 'LocalWorkItemWidgetConnection',
nodes: [],
......
#import './widget.fragment.graphql'
#import "./work_item.fragment.graphql"
mutation workItemUpdate($input: WorkItemUpdateInput!) {
workItemUpdate(input: $input) {
workItem {
id
title
workItemType {
id
}
widgets @client {
nodes {
...WidgetBase
}
}
...WorkItem
}
}
}
fragment WorkItem on WorkItem {
id
title
workItemType {
id
name
}
}
#import './widget.fragment.graphql'
#import "./work_item.fragment.graphql"
query WorkItem($id: ID!) {
query workItem($id: ID!) {
workItem(id: $id) {
id
title
workItemType {
id
}
widgets @client {
nodes {
...WidgetBase
}
}
...WorkItem
}
}
<script>
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import { GlAlert } from '@gitlab/ui';
import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import Tracking from '~/tracking';
import { i18n } from '../constants';
import workItemQuery from '../graphql/work_item.query.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import { WI_TITLE_TRACK_LABEL } from '../constants';
import ItemTitle from '../components/item_title.vue';
const trackingMixin = Tracking.mixin();
import WorkItemTitle from '../components/work_item_title.vue';
export default {
titleUpdatedEvent: 'updated_title',
i18n,
components: {
ItemTitle,
GlAlert,
GlLoadingIcon,
WorkItemTitle,
},
mixins: [trackingMixin],
props: {
id: {
type: String,
......@@ -27,7 +21,7 @@ export default {
data() {
return {
workItem: {},
error: false,
error: undefined,
};
},
apollo: {
......@@ -38,37 +32,17 @@ export default {
id: this.gid,
};
},
error() {
this.error = this.$options.i18n.fetchError;
},
},
},
computed: {
tracking() {
return {
category: 'workItems:show',
action: 'updated_title',
label: WI_TITLE_TRACK_LABEL,
property: '[type_work_item]',
};
},
gid() {
return convertToGraphQLId('WorkItem', this.id);
return convertToGraphQLId(TYPE_WORK_ITEM, this.id);
},
},
methods: {
async updateWorkItem(updatedTitle) {
try {
await this.$apollo.mutate({
mutation: updateWorkItemMutation,
variables: {
input: {
id: this.gid,
title: updatedTitle,
},
},
});
this.track();
} catch {
this.error = true;
}
workItemType() {
return this.workItem.workItemType?.name;
},
},
};
......@@ -76,23 +50,16 @@ export default {
<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>
<gl-loading-icon
v-if="$apollo.queries.workItem.loading"
size="md"
data-testid="loading-types"
/>
<template v-else>
<item-title
:initial-title="workItem.title"
data-testid="title"
@title-changed="updateWorkItem"
/>
</template>
</div>
<gl-alert v-if="error" variant="danger" @dismiss="error = false">
{{ error }}
</gl-alert>
<work-item-title
:loading="$apollo.queries.workItem.loading"
:work-item-id="workItem.id"
:work-item-title="workItem.title"
:work-item-type="workItemType"
@error="error = $event"
/>
</section>
</template>
......@@ -34987,9 +34987,6 @@ 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 ""
......@@ -42314,6 +42311,9 @@ msgstr ""
msgid "WorkItem|Something went wrong when fetching work item types. Please try again"
msgstr ""
msgid "WorkItem|Something went wrong while updating the work item. Please try again."
msgstr ""
msgid "WorkItem|Type"
msgstr ""
......
......@@ -6,7 +6,6 @@ import { stubComponent } from 'helpers/stub_component';
import { TEST_HOST } from 'helpers/test_constants';
import { mockTracking } from 'helpers/tracking_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createFlash from '~/flash';
import Description from '~/issues/show/components/description.vue';
import TaskList from '~/task_list';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
......@@ -320,15 +319,6 @@ describe('Description component', () => {
expect(findWorkItemDetailModal().props('visible')).toBe(false);
});
it('shows error on error', async () => {
const message = 'I am error';
await findTaskLink().trigger('click');
findWorkItemDetailModal().vm.$emit('error', message);
expect(createFlash).toHaveBeenCalledWith({ message });
});
it('tracks when opened', async () => {
const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
......
import { GlModal, GlLoadingIcon } from '@gitlab/ui';
import { GlAlert, GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import WorkItemTitle from '~/work_items/components/item_title.vue';
import WorkItemTitle from '~/work_items/components/work_item_title.vue';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import { i18n } from '~/work_items/constants';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import { workItemQueryResponse } from '../mock_data';
......@@ -13,10 +14,11 @@ describe('WorkItemDetailModal component', () => {
let wrapper;
Vue.use(VueApollo);
const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse);
const successHandler = jest.fn().mockResolvedValue({ data: workItemQueryResponse });
const findAlert = () => wrapper.findComponent(GlAlert);
const findModal = () => wrapper.findComponent(GlModal);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle);
const createComponent = ({ workItemId = '1', handler = successHandler } = {}) => {
......@@ -41,10 +43,6 @@ describe('WorkItemDetailModal component', () => {
createComponent({ workItemId: null });
});
it('renders empty title when there is no `workItemId` prop', () => {
expect(findWorkItemTitle().exists()).toBe(true);
});
it('skips the work item query', () => {
expect(successHandler).not.toHaveBeenCalled();
});
......@@ -55,12 +53,10 @@ describe('WorkItemDetailModal component', () => {
createComponent();
});
it('renders loading spinner', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
it('renders WorkItemTitle in loading state', () => {
createComponent();
it('does not render title', () => {
expect(findWorkItemTitle().exists()).toBe(false);
expect(findWorkItemTitle().props('loading')).toBe(true);
});
});
......@@ -70,23 +66,26 @@ describe('WorkItemDetailModal component', () => {
return waitForPromises();
});
it('does not render loading spinner', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
it('renders title', () => {
expect(findWorkItemTitle().exists()).toBe(true);
it('does not render WorkItemTitle in loading state', () => {
expect(findWorkItemTitle().props('loading')).toBe(false);
});
});
it('emits an error if query has errored', async () => {
it('shows an error message when the work item query was unsuccessful', async () => {
const errorHandler = jest.fn().mockRejectedValue('Oops');
createComponent({ handler: errorHandler });
await waitForPromises();
expect(errorHandler).toHaveBeenCalled();
expect(findAlert().text()).toBe(i18n.fetchError);
});
it('shows an error message when WorkItemTitle emits an `error` event', async () => {
createComponent();
findWorkItemTitle().vm.$emit('error', i18n.updateError);
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([
['Something went wrong when fetching the work item. Please try again.'],
]);
expect(findAlert().text()).toBe(i18n.updateError);
});
});
import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mockTracking } from 'helpers/tracking_helper';
import waitForPromises from 'helpers/wait_for_promises';
import ItemTitle from '~/work_items/components/item_title.vue';
import WorkItemTitle from '~/work_items/components/work_item_title.vue';
import { i18n } from '~/work_items/constants';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import { updateWorkItemMutationResponse, workItemQueryResponse } from '../mock_data';
describe('WorkItemTitle component', () => {
let wrapper;
Vue.use(VueApollo);
const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findItemTitle = () => wrapper.findComponent(ItemTitle);
const createComponent = ({ loading = false, mutationHandler = mutationSuccessHandler } = {}) => {
wrapper = shallowMount(WorkItemTitle, {
apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]),
propsData: {
loading,
workItemId: workItemQueryResponse.workItem.id,
workItemTitle: workItemQueryResponse.workItem.title,
workItemType: workItemQueryResponse.workItem.workItemType.name,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when loading', () => {
beforeEach(() => {
createComponent({ loading: true });
});
it('renders loading spinner', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
it('does not render title', () => {
expect(findItemTitle().exists()).toBe(false);
});
});
describe('when loaded', () => {
beforeEach(() => {
createComponent({ loading: false });
});
it('does not render loading spinner', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
it('renders title', () => {
expect(findItemTitle().props('initialTitle')).toBe(workItemQueryResponse.workItem.title);
});
});
describe('when updating the title', () => {
it('calls a mutation', () => {
const title = 'new title!';
createComponent();
findItemTitle().vm.$emit('title-changed', title);
expect(mutationSuccessHandler).toHaveBeenCalledWith({ input: { id: '1', title } });
});
it('does not call a mutation when the title has not changed', () => {
createComponent();
findItemTitle().vm.$emit('title-changed', workItemQueryResponse.workItem.title);
expect(mutationSuccessHandler).not.toHaveBeenCalled();
});
it('emits an error message when the mutation was unsuccessful', async () => {
createComponent({ mutationHandler: jest.fn().mockRejectedValue('Error!') });
findItemTitle().vm.$emit('title-changed', 'new title');
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([[i18n.updateError]]);
});
it('tracks editing the title', async () => {
const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
createComponent();
findItemTitle().vm.$emit('title-changed', 'new title');
await waitForPromises();
expect(trackingSpy).toHaveBeenCalledWith('workItems:show', 'updated_title', {
category: 'workItems:show',
label: 'item_title',
property: 'type_Task',
});
});
});
});
......@@ -6,6 +6,7 @@ export const workItemQueryResponse = {
workItemType: {
__typename: 'WorkItemType',
id: 'work-item-type-1',
name: 'Task',
},
widgets: {
__typename: 'LocalWorkItemWidgetConnection',
......@@ -31,6 +32,7 @@ export const updateWorkItemMutationResponse = {
workItemType: {
__typename: 'WorkItemType',
id: 'work-item-type-1',
name: 'Task',
},
widgets: {
__typename: 'LocalWorkItemWidgetConnection',
......@@ -73,6 +75,7 @@ export const createWorkItemMutationResponse = {
workItemType: {
__typename: 'WorkItemType',
id: 'work-item-type-1',
name: 'Task',
},
},
},
......
import Vue from 'vue';
import { GlAlert } from '@gitlab/ui';
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 { mockTracking, unmockTracking } from 'helpers/tracking_helper';
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, updateWorkItemMutationResponse } from '../mock_data';
import WorkItemTitle from '~/work_items/components/work_item_title.vue';
import { i18n } from '~/work_items/constants';
import { workItemQueryResponse } from '../mock_data';
Vue.use(VueApollo);
const WORK_ITEM_ID = '1';
const WORK_ITEM_GID = `gid://gitlab/WorkItem/${WORK_ITEM_ID}`;
describe('Work items root component', () => {
const mockUpdatedTitle = 'Updated title';
let wrapper;
let fakeApollo;
const findTitle = () => wrapper.findComponent(ItemTitle);
const createComponent = ({ queryResponse = workItemQueryResponse } = {}) => {
fakeApollo = createMockApollo(
[[updateWorkItemMutation, jest.fn().mockResolvedValue(updateWorkItemMutationResponse)]],
resolvers,
{
possibleTypes: {
LocalWorkItemWidget: ['LocalTitleWidget'],
},
},
);
fakeApollo.clients.defaultClient.cache.writeQuery({
query: workItemQuery,
variables: {
id: WORK_ITEM_GID,
},
data: queryResponse,
});
const successHandler = jest.fn().mockResolvedValue({ data: workItemQueryResponse });
const findAlert = () => wrapper.findComponent(GlAlert);
const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle);
const createComponent = ({ handler = successHandler } = {}) => {
wrapper = shallowMount(WorkItemsRoot, {
apolloProvider: createMockApollo([[workItemQuery, handler]]),
propsData: {
id: WORK_ITEM_ID,
},
apolloProvider: fakeApollo,
});
};
afterEach(() => {
wrapper.destroy();
fakeApollo = null;
});
it('renders the title', () => {
createComponent();
expect(findTitle().exists()).toBe(true);
expect(findTitle().props('initialTitle')).toBe('Test');
});
it('updates the title when it is edited', async () => {
createComponent();
jest.spyOn(wrapper.vm.$apollo, 'mutate');
describe('when loading', () => {
beforeEach(() => {
createComponent();
});
await findTitle().vm.$emit('title-changed', mockUpdatedTitle);
it('renders WorkItemTitle in loading state', () => {
createComponent();
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: updateWorkItemMutation,
variables: {
input: {
id: WORK_ITEM_GID,
title: mockUpdatedTitle,
},
},
expect(findWorkItemTitle().props('loading')).toBe(true);
});
});
describe('tracking', () => {
let trackingSpy;
describe('when loaded', () => {
beforeEach(() => {
trackingSpy = mockTracking('_category_', undefined, jest.spyOn);
createComponent();
return waitForPromises();
});
afterEach(() => {
unmockTracking();
it('does not render WorkItemTitle in loading state', () => {
expect(findWorkItemTitle().props('loading')).toBe(false);
});
});
it('tracks item title updates', async () => {
await findTitle().vm.$emit('title-changed', mockUpdatedTitle);
it('shows an error message when the work item query was unsuccessful', async () => {
const errorHandler = jest.fn().mockRejectedValue('Oops');
createComponent({ handler: errorHandler });
await waitForPromises();
await waitForPromises();
expect(errorHandler).toHaveBeenCalled();
expect(findAlert().text()).toBe(i18n.fetchError);
});
expect(trackingSpy).toHaveBeenCalledTimes(1);
expect(trackingSpy).toHaveBeenCalledWith('workItems:show', undefined, {
action: 'updated_title',
category: 'workItems:show',
label: 'item_title',
property: '[type_work_item]',
});
});
it('shows an error message when WorkItemTitle emits an `error` event', async () => {
createComponent();
findWorkItemTitle().vm.$emit('error', i18n.updateError);
await waitForPromises();
expect(findAlert().text()).toBe(i18n.updateError);
});
});
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