Skip to content
Snippets Groups Projects
Unverified Commit 42a15ee9 authored by Jose Ivan Vargas Lopez's avatar Jose Ivan Vargas Lopez
Browse files

Merge branch '392992-create-epic-in-work-items-list' into 'master'

Create epic work item in group work items list

See merge request https://gitlab.com/gitlab-org/gitlab/-/merge_requests/129936



Merged-by: default avatarJose Ivan Vargas <jvargas@gitlab.com>
Approved-by: default avatarMalcolm Locke <mlocke@gitlab.com>
Approved-by: default avatarSheldon Led <sheldonled@gitlab.com>
Approved-by: default avatarJose Ivan Vargas <jvargas@gitlab.com>
Reviewed-by: default avatarCoung Ngo <cngo@gitlab.com>
Reviewed-by: default avatarSheldon Led <sheldonled@gitlab.com>
Reviewed-by: default avatarMalcolm Locke <mlocke@gitlab.com>
Co-authored-by: default avatarCoung Ngo <cngo@gitlab.com>
parents 4f38075e f02875bd
No related branches found
No related tags found
No related merge requests found
Showing
with 300 additions and 55 deletions
Loading
Loading
@@ -203,11 +203,6 @@ export default {
required: false,
default: () => [],
},
eeIsOkrsEnabled: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
Loading
Loading
@@ -1024,14 +1019,11 @@ export default {
>
{{ $options.i18n.editIssues }}
</gl-button>
<gl-button
v-if="showNewIssueLink && !eeIsOkrsEnabled"
:href="newIssuePath"
variant="confirm"
>
{{ $options.i18n.newIssueLabel }}
</gl-button>
<slot name="new-objective-button"></slot>
<slot name="new-issuable-button">
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ $options.i18n.newIssueLabel }}
</gl-button>
</slot>
<new-resource-dropdown
v-if="showNewIssueDropdown"
:query="$options.searchProjectsQuery"
Loading
Loading
Loading
Loading
@@ -95,7 +95,7 @@ export default {
return Boolean(this.issuable.externalTracker);
},
isIssuableUrlExternal() {
return isExternal(this.webUrl);
return isExternal(this.webUrl ?? '');
},
reference() {
return this.issuable.reference || `${this.issuableSymbol}${this.issuable.iid}`;
Loading
Loading
Loading
Loading
@@ -36,6 +36,7 @@ export const WORK_ITEM_TYPE_ENUM_REQUIREMENTS = 'REQUIREMENTS';
export const WORK_ITEM_TYPE_ENUM_OBJECTIVE = 'OBJECTIVE';
export const WORK_ITEM_TYPE_ENUM_KEY_RESULT = 'KEY_RESULT';
 
export const WORK_ITEM_TYPE_VALUE_EPIC = 'Epic';
export const WORK_ITEM_TYPE_VALUE_INCIDENT = 'Incident';
export const WORK_ITEM_TYPE_VALUE_ISSUE = 'Issue';
export const WORK_ITEM_TYPE_VALUE_TASK = 'Task';
Loading
Loading
query groupWorkItemTypes($fullPath: ID!) {
workspace: group(fullPath: $fullPath) {
id
workItemTypes {
nodes {
id
name
}
}
}
}
Loading
Loading
@@ -67,6 +67,10 @@ export default {
:tabs="$options.issuableListTabs"
@dismiss-alert="error = undefined"
>
<template #nav-actions>
<slot name="nav-actions"></slot>
</template>
<template #timeframe="{ issuable = {} }">
<issue-card-time-info :issue="issuable" />
</template>
Loading
Loading
@@ -78,5 +82,9 @@ export default {
<template #statistics="{ issuable = {} }">
<issue-card-statistics :issue="issuable" />
</template>
<template #list-body>
<slot name="list-body"></slot>
</template>
</issuable-list>
</template>
Loading
Loading
@@ -2,7 +2,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import WorkItemsListApp from './components/work_items_list_app.vue';
import WorkItemsListApp from 'ee_else_ce/work_items/list/components/work_items_list_app.vue';
 
export const mountWorkItemsListApp = () => {
const el = document.querySelector('.js-work-items-list-root');
Loading
Loading
@@ -13,7 +13,12 @@ export const mountWorkItemsListApp = () => {
 
Vue.use(VueApollo);
 
const { fullPath, hasIssuableHealthStatusFeature, hasIssueWeightsFeature } = el.dataset;
const {
fullPath,
hasEpicsFeature,
hasIssuableHealthStatusFeature,
hasIssueWeightsFeature,
} = el.dataset;
 
return new Vue({
el,
Loading
Loading
@@ -23,6 +28,7 @@ export const mountWorkItemsListApp = () => {
}),
provide: {
fullPath,
hasEpicsFeature: parseBoolean(hasEpicsFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
},
Loading
Loading
Loading
Loading
@@ -169,12 +169,11 @@ export default {
<template>
<issues-list-app
ref="issuesListApp"
:ee-is-okrs-enabled="isOkrsEnabled"
:ee-work-item-types="workItemTypes"
:ee-type-token-options="typeTokenOptions"
:ee-search-tokens="searchTokens"
>
<template v-if="isOkrsEnabled" #new-objective-button>
<template v-if="isOkrsEnabled" #new-issuable-button>
<new-issue-dropdown
:work-item-type="$options.WORK_ITEM_TYPE_VALUE_OBJECTIVE"
@select-new-work-item="showForm"
Loading
Loading
Loading
Loading
@@ -2,6 +2,7 @@
import { GlAlert, GlButton, GlForm, GlFormCheckbox, GlFormGroup, GlFormInput } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql';
import groupWorkItemTypesQuery from '~/work_items/graphql/group_work_item_types.query.graphql';
import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
import {
I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL,
Loading
Loading
@@ -22,6 +23,11 @@ export default {
},
inject: ['fullPath'],
props: {
isGroup: {
type: Boolean,
required: false,
default: false,
},
workItemType: {
type: String,
required: true,
Loading
Loading
@@ -38,7 +44,9 @@ export default {
},
apollo: {
workItemTypes: {
query: projectWorkItemTypesQuery,
query() {
return this.isGroup ? groupWorkItemTypesQuery : projectWorkItemTypesQuery;
},
variables() {
return {
fullPath: this.fullPath,
Loading
Loading
@@ -79,7 +87,7 @@ export default {
variables: {
input: {
title: this.title,
projectPath: this.fullPath,
namespacePath: this.fullPath,
workItemTypeId: this.workItemTypeId,
confidential: this.confidential,
},
Loading
Loading
<script>
import { GlButton } from '@gitlab/ui';
import { WORK_ITEM_TYPE_VALUE_EPIC } from '~/work_items/constants';
import WorkItemsListApp from '~/work_items/list/components/work_items_list_app.vue';
import CreateWorkItemForm from '../../components/create_work_item_form.vue';
export default {
WORK_ITEM_TYPE_VALUE_EPIC,
components: {
CreateWorkItemForm,
GlButton,
WorkItemsListApp,
},
inject: ['hasEpicsFeature'],
data() {
return {
showEpicCreationForm: false,
};
},
methods: {
handleCreated({ workItem }) {
if (workItem.id) {
// Refresh results on list
this.showEpicCreationForm = false;
this.$refs.workItemsListApp.$apollo.queries.workItems.refetch();
}
},
hideForm() {
this.showEpicCreationForm = false;
},
showForm() {
this.showEpicCreationForm = true;
},
},
};
</script>
<template>
<work-items-list-app ref="workItemsListApp">
<template v-if="hasEpicsFeature" #nav-actions>
<gl-button variant="confirm" @click="showForm">
{{ __('Create epic') }}
</gl-button>
</template>
<template v-if="hasEpicsFeature && showEpicCreationForm" #list-body>
<create-work-item-form
is-group
:work-item-type="$options.WORK_ITEM_TYPE_VALUE_EPIC"
@created="handleCreated"
@hide="hideForm"
/>
</template>
</work-items-list-app>
</template>
Loading
Loading
@@ -17,6 +17,7 @@ def work_items_index_data(project)
override :work_items_list_data
def work_items_list_data(group)
super.merge(
has_epics_feature: group.licensed_feature_available?(:epics).to_s,
has_issuable_health_status_feature: group.licensed_feature_available?(:issuable_health_status).to_s,
has_issue_weights_feature: group.licensed_feature_available?(:issue_weights).to_s
)
Loading
Loading
import { mount } from '@vue/test-utils';
import { cloneDeep } from 'lodash';
import Vue from 'vue';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql';
import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql';
Loading
Loading
@@ -41,6 +41,8 @@ import {
} from 'ee/vue_shared/components/filtered_search_bar/constants';
import BlockingIssuesCount from 'ee/issues/components/blocking_issues_count.vue';
import IssuesListApp from 'ee/issues/list/components/issues_list_app.vue';
import NewIssueDropdown from 'ee/issues/list/components/new_issue_dropdown.vue';
import CreateWorkItemForm from 'ee/work_items/components/create_work_item_form.vue';
 
describe('EE IssuesListApp component', () => {
let wrapper;
Loading
Loading
@@ -89,8 +91,9 @@ describe('EE IssuesListApp component', () => {
defaultQueryResponse.data.project.issues.nodes[0].weight = 5;
 
const findIssuableList = () => wrapper.findComponent(IssuableList);
const findIssueListApp = () => wrapper.findComponent(CEIssuesListApp);
const findCreateWorkItemForm = () => wrapper.findComponent(CreateWorkItemForm);
const findNewIssueDropdown = () => wrapper.findComponent(NewIssueDropdown);
 
const mountComponent = ({
provide = {},
Loading
Loading
@@ -182,31 +185,6 @@ describe('EE IssuesListApp component', () => {
);
});
 
describe('isOkrsEnabled', () => {
describe.each`
hasOkrsFeature | okrsMvc | eeIsOkrsEnabled | message
${false} | ${true} | ${false} | ${'false'}
${true} | ${false} | ${false} | ${'false'}
${true} | ${true} | ${true} | ${'true'}
`(
'when hasOkrsFeature is "$hasOkrsFeature" and okrsMvc is "$okrsMvc"',
({ hasOkrsFeature, okrsMvc, eeIsOkrsEnabled, message }) => {
beforeEach(() => {
wrapper = mountComponent({
provide: {
hasOkrsFeature,
},
okrsMvc,
});
});
it(`should have eeIsOkrsEnabled value to be ${message} `, () => {
expect(findIssueListApp().props('eeIsOkrsEnabled')).toBe(eeIsOkrsEnabled);
});
},
);
});
describe('tokens', () => {
const mockCurrentUser = {
id: 1,
Loading
Loading
@@ -278,4 +256,80 @@ describe('EE IssuesListApp component', () => {
});
});
});
describe('NewIssueDropdown component', () => {
describe('when okrs is enabled', () => {
beforeEach(() => {
wrapper = mountComponent({
provide: { hasOkrsFeature: true },
okrsMvc: true,
});
});
it('renders', () => {
expect(findNewIssueDropdown().props()).toEqual({ workItemType: 'Objective' });
});
});
describe('when okrs is disabled', () => {
beforeEach(() => {
wrapper = mountComponent({
provide: { hasOkrsFeature: false },
okrsMvc: false,
});
});
it('does not render', () => {
expect(findNewIssueDropdown().exists()).toBe(false);
});
});
});
describe('CreateWorkItemForm component', () => {
describe('when okrs is enabled', () => {
beforeEach(() => {
wrapper = mountComponent({
provide: { hasOkrsFeature: true },
okrsMvc: true,
});
});
it('does not render initially', () => {
expect(findCreateWorkItemForm().exists()).toBe(false);
});
describe('when "New Objective" button is clicked', () => {
beforeEach(() => {
findNewIssueDropdown().vm.$emit('select-new-work-item');
});
it('renders', () => {
expect(findCreateWorkItemForm().props()).toEqual({
isGroup: false,
workItemType: 'Objective',
});
});
it('hides form when "hide" event is emitted', async () => {
findCreateWorkItemForm().vm.$emit('hide');
await nextTick();
expect(findCreateWorkItemForm().exists()).toBe(false);
});
});
});
describe('when okrs is disabled', () => {
beforeEach(() => {
wrapper = mountComponent({
provide: { hasOkrsFeature: false },
okrsMvc: false,
});
});
it('does not render', () => {
expect(findCreateWorkItemForm().exists()).toBe(false);
});
});
});
});
Loading
Loading
@@ -7,10 +7,11 @@ import waitForPromises from 'helpers/wait_for_promises';
import CreateWorkItemForm from 'ee/work_items/components/create_work_item_form.vue';
import { __ } from '~/locale';
import { WORK_ITEM_TYPE_VALUE_OBJECTIVE } from '~/work_items/constants';
import groupWorkItemTypesQuery from '~/work_items/graphql/group_work_item_types.query.graphql';
import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql';
import {
projectWorkItemTypesQueryResponse,
groupOrProjectWorkItemTypesQueryResponse,
createWorkItemMutationResponse,
createWorkItemMutationErrorResponse,
} from '../mock_data';
Loading
Loading
@@ -20,7 +21,12 @@ Vue.use(VueApollo);
describe('Create work item Objective component', () => {
let wrapper;
 
const querySuccessHandler = jest.fn().mockResolvedValue(projectWorkItemTypesQueryResponse);
const groupQuerySuccessHandler = jest
.fn()
.mockResolvedValue(groupOrProjectWorkItemTypesQueryResponse);
const projectQuerySuccessHandler = jest
.fn()
.mockResolvedValue(groupOrProjectWorkItemTypesQueryResponse);
const mutationSuccessHandler = jest.fn().mockResolvedValue(createWorkItemMutationResponse);
const mutationErrorHandler = jest.fn().mockResolvedValue(createWorkItemMutationErrorResponse);
 
Loading
Loading
@@ -36,15 +42,19 @@ describe('Create work item Objective component', () => {
};
 
const createComponent = ({
queryHandler = querySuccessHandler,
isGroup = false,
groupQueryHandler = groupQuerySuccessHandler,
projectQueryHandler = projectQuerySuccessHandler,
mutationHandler = mutationSuccessHandler,
} = {}) => {
wrapper = shallowMount(CreateWorkItemForm, {
apolloProvider: createMockApollo([
[projectWorkItemTypesQuery, queryHandler],
[groupWorkItemTypesQuery, groupQueryHandler],
[projectWorkItemTypesQuery, projectQueryHandler],
[createWorkItemMutation, mutationHandler],
]),
propsData: {
isGroup,
workItemType: WORK_ITEM_TYPE_VALUE_OBJECTIVE,
},
provide: {
Loading
Loading
@@ -87,7 +97,7 @@ describe('Create work item Objective component', () => {
});
 
it('hides the alert on dismissing the error', async () => {
createComponent({ queryHandler: jest.fn().mockRejectedValue('oh no') });
createComponent({ projectQueryHandler: jest.fn().mockRejectedValue('oh no') });
await waitForPromises();
 
expect(findAlert().exists()).toBe(true);
Loading
Loading
@@ -114,7 +124,7 @@ describe('Create work item Objective component', () => {
input: {
title: mockTitle,
confidential: false,
projectPath: 'full-path',
namespacePath: 'full-path',
},
});
});
Loading
Loading
@@ -127,7 +137,7 @@ describe('Create work item Objective component', () => {
input: {
title: mockTitle,
confidential: true,
projectPath: 'full-path',
namespacePath: 'full-path',
},
});
});
Loading
Loading
@@ -137,6 +147,36 @@ describe('Create work item Objective component', () => {
});
});
 
describe('work item types query', () => {
describe('when project context', () => {
beforeEach(() => {
createComponent({ isGroup: false });
});
it('calls project query', () => {
expect(projectQuerySuccessHandler).toHaveBeenCalled();
});
it('does not call group query', () => {
expect(groupQuerySuccessHandler).not.toHaveBeenCalled();
});
});
describe('when group context', () => {
beforeEach(() => {
createComponent({ isGroup: true });
});
it('calls group query', () => {
expect(groupQuerySuccessHandler).toHaveBeenCalled();
});
it('does not call project query', () => {
expect(projectQuerySuccessHandler).not.toHaveBeenCalled();
});
});
});
it('shows an alert on mutation error', async () => {
createComponent({ mutationHandler: mutationErrorHandler });
await waitForPromises();
Loading
Loading
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import CreateWorkItemForm from 'ee/work_items/components/create_work_item_form.vue';
import EEWorkItemsListApp from 'ee/work_items/list/components/work_items_list_app.vue';
describe('WorkItemsListApp EE component', () => {
let wrapper;
const findCreateWorkItemForm = () => wrapper.findComponent(CreateWorkItemForm);
const findGlButton = () => wrapper.findComponent(GlButton);
const mountComponent = ({ hasEpicsFeature = false } = {}) => {
wrapper = shallowMount(EEWorkItemsListApp, {
provide: {
hasEpicsFeature,
},
});
};
describe('when epics feature is available', () => {
beforeEach(() => {
mountComponent({ hasEpicsFeature: true });
});
it('renders "Create epic" button', () => {
expect(findGlButton().text()).toBe('Create epic');
});
it('does not render "Create epic" form initially', () => {
expect(findCreateWorkItemForm().exists()).toBe(false);
});
describe('when "Create epic" button is clicked', () => {
beforeEach(() => {
findGlButton().vm.$emit('click');
});
it('renders "Create epic" form', () => {
expect(findCreateWorkItemForm().props()).toEqual({
isGroup: true,
workItemType: 'Epic',
});
});
it('hides form when "hide" event is emitted', async () => {
findCreateWorkItemForm().vm.$emit('hide');
await nextTick();
expect(findCreateWorkItemForm().exists()).toBe(false);
});
});
});
describe('when epics feature is not available', () => {
beforeEach(() => {
mountComponent({ hasEpicsFeature: false });
});
it('does not render "Create epic" button', () => {
expect(findGlButton().exists()).toBe(false);
});
it('does not render "Create epic" form', () => {
expect(findCreateWorkItemForm().exists()).toBe(false);
});
});
});
export const projectWorkItemTypesQueryResponse = {
export const groupOrProjectWorkItemTypesQueryResponse = {
data: {
workspace: {
id: 'gid://gitlab/WorkItem/1',
Loading
Loading
Loading
Loading
@@ -55,6 +55,7 @@
 
before do
stub_licensed_features(
epics: feature_available,
issuable_health_status: feature_available,
issue_weights: feature_available
)
Loading
Loading
@@ -66,6 +67,7 @@
it 'returns true for the features' do
expect(work_items_list_data).to include(
{
has_epics_feature: "true",
has_issuable_health_status_feature: "true",
has_issue_weights_feature: "true"
}
Loading
Loading
@@ -79,6 +81,7 @@
it 'returns false for the features' do
expect(work_items_list_data).to include(
{
has_epics_feature: "false",
has_issuable_health_status_feature: "false",
has_issue_weights_feature: "false"
}
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment