Commit 2f6ff723 authored by Coung Ngo's avatar Coung Ngo

Add GraphQL subscription to work item title

Add real time updates for work item title.
Behind feature flag `work_items` defaulted off.
parent 98e8ece0
......@@ -4,7 +4,7 @@ import { __ } from '~/locale';
export default {
props: {
initialTitle: {
title: {
type: String,
required: false,
default: '',
......@@ -20,11 +20,6 @@ export default {
default: false,
},
},
data() {
return {
title: this.initialTitle,
};
},
methods: {
getSanitizedTitle(inputEl) {
const { innerText } = inputEl;
......
<script>
import { GlAlert } from '@gitlab/ui';
import { i18n } from '../constants';
import workItemQuery from '../graphql/work_item.query.graphql';
import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql';
import WorkItemTitle from './work_item_title.vue';
export default {
i18n,
components: {
GlAlert,
WorkItemTitle,
},
props: {
workItemId: {
type: String,
required: false,
default: null,
},
},
data() {
return {
error: undefined,
workItem: {},
};
},
apollo: {
workItem: {
query: workItemQuery,
variables() {
return {
id: this.workItemId,
};
},
skip() {
return !this.workItemId;
},
error() {
this.error = this.$options.i18n.fetchError;
},
subscribeToMore: {
document: workItemTitleSubscription,
variables() {
return {
issuableId: this.workItemId,
};
},
},
},
},
computed: {
workItemType() {
return this.workItem.workItemType?.name;
},
},
};
</script>
<template>
<section>
<gl-alert v-if="error" variant="danger" @dismiss="error = undefined">
{{ 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>
<script>
import { GlAlert, GlModal } from '@gitlab/ui';
import { i18n } from '../constants';
import workItemQuery from '../graphql/work_item.query.graphql';
import WorkItemTitle from './work_item_title.vue';
import { GlModal } from '@gitlab/ui';
import WorkItemDetail from './work_item_detail.vue';
export default {
i18n,
components: {
GlAlert,
GlModal,
WorkItemTitle,
WorkItemDetail,
},
props: {
visible: {
......@@ -22,48 +18,11 @@ export default {
default: null,
},
},
data() {
return {
error: undefined,
workItem: {},
};
},
apollo: {
workItem: {
query: workItemQuery,
variables() {
return {
id: this.workItemId,
};
},
skip() {
return !this.workItemId;
},
error() {
this.error = this.$options.i18n.fetchError;
},
},
},
computed: {
workItemType() {
return this.workItem.workItemType?.name;
},
},
};
</script>
<template>
<gl-modal hide-footer modal-id="work-item-detail-modal" :visible="visible" @hide="$emit('close')">
<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"
/>
<work-item-detail :work-item-id="workItemId" />
</gl-modal>
</template>
......@@ -43,7 +43,7 @@ export default {
},
},
methods: {
async updateWorkItem(updatedTitle) {
async updateTitle(updatedTitle) {
if (updatedTitle === this.workItemTitle) {
return;
}
......@@ -69,5 +69,5 @@ export default {
<template>
<gl-loading-icon v-if="loading" class="gl-mt-3" size="md" />
<item-title v-else :initial-title="workItemTitle" @title-changed="updateWorkItem" />
<item-title v-else :title="workItemTitle" @title-changed="updateTitle" />
</template>
subscription issuableTitleUpdated($issuableId: IssuableID!) {
issuableTitleUpdated(issuableId: $issuableId) {
... on WorkItem {
id
title
}
}
}
......@@ -189,11 +189,7 @@ export default {
<form @submit.prevent="createWorkItem">
<gl-alert v-if="error" variant="danger" @dismiss="error = null">{{ error }}</gl-alert>
<div :class="{ 'gl-px-5': isModal }" data-testid="content">
<item-title
:initial-title="title"
data-testid="title-input"
@title-input="handleTitleInput"
/>
<item-title :title="title" data-testid="title-input" @title-input="handleTitleInput" />
<div>
<gl-loading-icon
v-if="$apollo.queries.workItemTypes.loading"
......
<script>
import { GlAlert } from '@gitlab/ui';
import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { i18n } from '../constants';
import workItemQuery from '../graphql/work_item.query.graphql';
import WorkItemTitle from '../components/work_item_title.vue';
import WorkItemDetail from '../components/work_item_detail.vue';
export default {
i18n,
components: {
GlAlert,
WorkItemTitle,
WorkItemDetail,
},
props: {
id: {
......@@ -18,48 +13,14 @@ export default {
required: true,
},
},
data() {
return {
workItem: {},
error: undefined,
};
},
apollo: {
workItem: {
query: workItemQuery,
variables() {
return {
id: this.gid,
};
},
error() {
this.error = this.$options.i18n.fetchError;
},
},
},
computed: {
gid() {
return convertToGraphQLId(TYPE_WORK_ITEM, this.id);
},
workItemType() {
return this.workItem.workItemType?.name;
},
},
};
</script>
<template>
<section>
<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>
<work-item-detail :work-item-id="gid" />
</template>
......@@ -4,10 +4,10 @@ import ItemTitle from '~/work_items/components/item_title.vue';
jest.mock('lodash/escape', () => jest.fn((fn) => fn));
const createComponent = ({ initialTitle = 'Sample title', disabled = false } = {}) =>
const createComponent = ({ title = 'Sample title', disabled = false } = {}) =>
shallowMount(ItemTitle, {
propsData: {
initialTitle,
title,
disabled,
},
});
......
import { GlAlert, GlModal } from '@gitlab/ui';
import { 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/work_item_title.vue';
import WorkItemDetail from '~/work_items/components/work_item_detail.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';
describe('WorkItemDetailModal component', () => {
let wrapper;
Vue.use(VueApollo);
const successHandler = jest.fn().mockResolvedValue({ data: workItemQueryResponse });
const findAlert = () => wrapper.findComponent(GlAlert);
const findModal = () => wrapper.findComponent(GlModal);
const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle);
const findWorkItemDetail = () => wrapper.findComponent(WorkItemDetail);
const createComponent = ({ workItemId = '1', handler = successHandler } = {}) => {
const createComponent = ({ visible = true, workItemId = '1' } = {}) => {
wrapper = shallowMount(WorkItemDetailModal, {
apolloProvider: createMockApollo([[workItemQuery, handler]]),
propsData: { visible: true, workItemId },
propsData: { visible, workItemId },
});
};
......@@ -32,60 +23,17 @@ describe('WorkItemDetailModal component', () => {
wrapper.destroy();
});
it('renders modal', () => {
createComponent();
expect(findModal().props()).toMatchObject({ visible: true });
});
describe('when there is no `workItemId` prop', () => {
beforeEach(() => {
createComponent({ workItemId: null });
});
describe.each([true, false])('when visible=%s', (visible) => {
it(`${visible ? 'renders' : 'does not render'} modal`, () => {
createComponent({ visible });
it('skips the work item query', () => {
expect(successHandler).not.toHaveBeenCalled();
expect(findModal().props('visible')).toBe(visible);
});
});
describe('when loading', () => {
beforeEach(() => {
createComponent();
});
it('renders WorkItemTitle in loading state', () => {
createComponent();
expect(findWorkItemTitle().props('loading')).toBe(true);
});
});
describe('when loaded', () => {
beforeEach(() => {
createComponent();
return waitForPromises();
});
it('does not render WorkItemTitle in loading state', () => {
expect(findWorkItemTitle().props('loading')).toBe(false);
});
});
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 () => {
it('renders WorkItemDetail', () => {
createComponent();
findWorkItemTitle().vm.$emit('error', i18n.updateError);
await waitForPromises();
expect(findAlert().text()).toBe(i18n.updateError);
expect(findWorkItemDetail().props()).toEqual({ workItemId: '1' });
});
});
......@@ -61,7 +61,7 @@ describe('WorkItemTitle component', () => {
});
it('renders title', () => {
expect(findItemTitle().props('initialTitle')).toBe(workItemQueryResponse.workItem.title);
expect(findItemTitle().props('title')).toBe(workItemQueryResponse.workItem.title);
});
});
......
......@@ -95,3 +95,12 @@ export const createWorkItemFromTaskMutationResponse = {
},
},
};
export const workItemTitleSubscriptionResponse = {
data: {
issuableTitleUpdated: {
id: 'gid://gitlab/WorkItem/1',
title: 'new title',
},
},
};
......@@ -212,7 +212,7 @@ describe('Create work item component', () => {
createComponent({
props: { initialTitle },
});
expect(findTitleInput().props('initialTitle')).toBe(initialTitle);
expect(findTitleInput().props('title')).toBe(initialTitle);
});
describe('when title input field has a text', () => {
......
import { GlAlert } 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 WorkItemDetail from '~/work_items/components/work_item_detail.vue';
import WorkItemTitle from '~/work_items/components/work_item_title.vue';
import { i18n } from '~/work_items/constants';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql';
import { workItemTitleSubscriptionResponse, workItemQueryResponse } from '../mock_data';
describe('WorkItemDetail component', () => {
let wrapper;
Vue.use(VueApollo);
const successHandler = jest.fn().mockResolvedValue({ data: workItemQueryResponse });
const initialSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse);
const findAlert = () => wrapper.findComponent(GlAlert);
const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle);
const createComponent = ({
workItemId = workItemQueryResponse.workItem.id,
handler = successHandler,
subscriptionHandler = initialSubscriptionHandler,
} = {}) => {
wrapper = shallowMount(WorkItemDetail, {
apolloProvider: createMockApollo([
[workItemQuery, handler],
[workItemTitleSubscription, subscriptionHandler],
]),
propsData: { workItemId },
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when there is no `workItemId` prop', () => {
beforeEach(() => {
createComponent({ workItemId: null });
});
it('skips the work item query', () => {
expect(successHandler).not.toHaveBeenCalled();
});
});
describe('when loading', () => {
beforeEach(() => {
createComponent();
});
it('renders WorkItemTitle in loading state', () => {
expect(findWorkItemTitle().props('loading')).toBe(true);
});
});
describe('when loaded', () => {
beforeEach(() => {
createComponent();
return waitForPromises();
});
it('does not render WorkItemTitle in loading state', () => {
expect(findWorkItemTitle().props('loading')).toBe(false);
});
});
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(findAlert().text()).toBe(i18n.updateError);
});
it('calls the subscription', () => {
createComponent();
expect(initialSubscriptionHandler).toHaveBeenCalledWith({
issuableId: workItemQueryResponse.workItem.id,
});
});
});
import Vue from 'vue';
import { GlAlert } 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 workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import WorkItemDetail from '~/work_items/components/work_item_detail.vue';
import WorkItemsRoot from '~/work_items/pages/work_item_root.vue';
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';
describe('Work items root component', () => {
let wrapper;
const successHandler = jest.fn().mockResolvedValue({ data: workItemQueryResponse });
const findWorkItemDetail = () => wrapper.findComponent(WorkItemDetail);
const findAlert = () => wrapper.findComponent(GlAlert);
const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle);
const createComponent = ({ handler = successHandler } = {}) => {
const createComponent = () => {
wrapper = shallowMount(WorkItemsRoot, {
apolloProvider: createMockApollo([[workItemQuery, handler]]),
propsData: {
id: WORK_ITEM_ID,
id: '1',
},
});
};
......@@ -35,44 +23,9 @@ describe('Work items root component', () => {
wrapper.destroy();
});
describe('when loading', () => {
beforeEach(() => {
createComponent();
});
it('renders WorkItemTitle in loading state', () => {
createComponent();
expect(findWorkItemTitle().props('loading')).toBe(true);
});
});
describe('when loaded', () => {
beforeEach(() => {
createComponent();
return waitForPromises();
});
it('does not render WorkItemTitle in loading state', () => {
expect(findWorkItemTitle().props('loading')).toBe(false);
});
});
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 () => {
it('renders WorkItemDetail', () => {
createComponent();
findWorkItemTitle().vm.$emit('error', i18n.updateError);
await waitForPromises();
expect(findAlert().text()).toBe(i18n.updateError);
expect(findWorkItemDetail().props()).toEqual({ workItemId: 'gid://gitlab/WorkItem/1' });
});
});
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