Commit afe6e9e1 authored by David O'Regan's avatar David O'Regan Committed by Natalia Tepluhina

Allow issue type change for incidents

parent 57422633
...@@ -5,8 +5,16 @@ import { deprecatedCreateFlash as createFlash } from '~/flash'; ...@@ -5,8 +5,16 @@ import { deprecatedCreateFlash as createFlash } from '~/flash';
import Poll from '~/lib/utils/poll'; import Poll from '~/lib/utils/poll';
import { visitUrl } from '~/lib/utils/url_utility'; import { visitUrl } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale'; import { __, s__, sprintf } from '~/locale';
import { IssuableStatus, IssuableStatusText, IssuableType } from '../constants'; import {
IssuableStatus,
IssuableStatusText,
IssuableType,
IssueTypePath,
IncidentTypePath,
IncidentType,
} from '../constants';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import getIssueStateQuery from '../queries/get_issue_state.query.graphql';
import Service from '../services/index'; import Service from '../services/index';
import Store from '../stores'; import Store from '../stores';
import descriptionComponent from './description.vue'; import descriptionComponent from './description.vue';
...@@ -195,8 +203,14 @@ export default { ...@@ -195,8 +203,14 @@ export default {
showForm: false, showForm: false,
templatesRequested: false, templatesRequested: false,
isStickyHeaderShowing: false, isStickyHeaderShowing: false,
issueState: {},
}; };
}, },
apollo: {
issueState: {
query: getIssueStateQuery,
},
},
computed: { computed: {
issuableTemplates() { issuableTemplates() {
return this.store.formState.issuableTemplates; return this.store.formState.issuableTemplates;
...@@ -288,7 +302,7 @@ export default { ...@@ -288,7 +302,7 @@ export default {
methods: { methods: {
handleBeforeUnloadEvent(e) { handleBeforeUnloadEvent(e) {
const event = e; const event = e;
if (this.showForm && this.issueChanged) { if (this.showForm && this.issueChanged && !this.issueState.isDirty) {
event.returnValue = __('Are you sure you want to lose your issue information?'); event.returnValue = __('Are you sure you want to lose your issue information?');
} }
return undefined; return undefined;
...@@ -346,14 +360,32 @@ export default { ...@@ -346,14 +360,32 @@ export default {
}, },
updateIssuable() { updateIssuable() {
const {
store: { formState },
issueState,
} = this;
const issuablePayload = issueState.isDirty
? { ...formState, issue_type: issueState.issueType }
: formState;
this.clearFlash(); this.clearFlash();
return this.service return this.service
.updateIssuable(this.store.formState) .updateIssuable(issuablePayload)
.then((res) => res.data) .then((res) => res.data)
.then((data) => { .then((data) => {
if (!window.location.pathname.includes(data.web_url)) { if (
!window.location.pathname.includes(data.web_url) &&
issueState.issueType !== IncidentType
) {
visitUrl(data.web_url); visitUrl(data.web_url);
} }
if (issueState.isDirty) {
const URI =
issueState.issueType === IncidentType
? data.web_url.replace(IssueTypePath, IncidentTypePath)
: data.web_url;
visitUrl(URI);
}
}) })
.then(this.updateStoreState) .then(this.updateStoreState)
.then(() => { .then(() => {
......
<script> <script>
import { GlButton } from '@gitlab/ui'; import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import updateMixin from '../mixins/update'; import updateMixin from '../mixins/update';
import getIssueStateQuery from '../queries/get_issue_state.query.graphql';
const issuableTypes = { const issuableTypes = {
issue: __('Issue'), issue: __('Issue'),
epic: __('Epic'), epic: __('Epic'),
incident: __('Incident'),
}; };
export default { export default {
components: { components: {
GlButton, GlButton,
GlModal,
},
directives: {
GlModal: GlModalDirective,
}, },
mixins: [updateMixin], mixins: [updateMixin],
props: { props: {
...@@ -36,19 +43,56 @@ export default { ...@@ -36,19 +43,56 @@ export default {
data() { data() {
return { return {
deleteLoading: false, deleteLoading: false,
skipApollo: false,
issueState: {},
modalId: uniqueId('delete-issuable-modal-'),
}; };
}, },
apollo: {
issueState: {
query: getIssueStateQuery,
skip() {
return this.skipApollo;
},
result() {
this.skipApollo = true;
},
},
},
computed: { computed: {
deleteIssuableButtonText() {
return sprintf(__('Delete %{issuableType}'), {
issuableType: this.typeToShow.toLowerCase(),
});
},
deleteIssuableModalText() {
return this.issuableType === 'epic'
? __('Delete this epic and all descendants?')
: sprintf(__('%{issuableType} will be removed! Are you sure?'), {
issuableType: this.typeToShow,
});
},
isSubmitEnabled() { isSubmitEnabled() {
return this.formState.title.trim() !== ''; return this.formState.title.trim() !== '';
}, },
modalActionProps() {
return {
primary: {
text: this.deleteIssuableButtonText,
attributes: [{ variant: 'danger' }, { loading: this.deleteLoading }],
},
cancel: {
text: __('Cancel'),
},
};
},
shouldShowDeleteButton() { shouldShowDeleteButton() {
return this.canDestroy && this.showDeleteButton; return this.canDestroy && this.showDeleteButton;
}, },
deleteIssuableButtonText() { typeToShow() {
return sprintf(__('Delete %{issuableType}'), { const { issueState, issuableType } = this;
issuableType: issuableTypes[this.issuableType].toLowerCase(), const type = issueState.issueType ?? issuableType;
}); return issuableTypes[type];
}, },
}, },
methods: { methods: {
...@@ -56,49 +100,57 @@ export default { ...@@ -56,49 +100,57 @@ export default {
eventHub.$emit('close.form'); eventHub.$emit('close.form');
}, },
deleteIssuable() { deleteIssuable() {
const confirmMessage = this.deleteLoading = true;
this.issuableType === 'epic' eventHub.$emit('delete.issuable', { destroy_confirm: true });
? __('Delete this epic and all descendants?')
: sprintf(__('%{issuableType} will be removed! Are you sure?'), {
issuableType: issuableTypes[this.issuableType],
});
// eslint-disable-next-line no-alert
if (window.confirm(confirmMessage)) {
this.deleteLoading = true;
eventHub.$emit('delete.issuable', { destroy_confirm: true });
}
}, },
}, },
}; };
</script> </script>
<template> <template>
<div class="gl-mt-3 gl-mb-3 clearfix"> <div class="gl-mt-3 gl-mb-3 gl-display-flex gl-justify-content-space-between">
<gl-button <div>
:loading="formState.updateLoading" <gl-button
:disabled="formState.updateLoading || !isSubmitEnabled" :loading="formState.updateLoading"
category="primary" :disabled="formState.updateLoading || !isSubmitEnabled"
variant="confirm" category="primary"
class="float-left qa-save-button gl-mr-3" variant="confirm"
type="submit" class="qa-save-button gl-mr-3"
@click.prevent="updateIssuable" data-testid="issuable-save-button"
> type="submit"
{{ __('Save changes') }} @click.prevent="updateIssuable"
</gl-button> >
<gl-button @click="closeForm"> {{ __('Save changes') }}
{{ __('Cancel') }} </gl-button>
</gl-button> <gl-button data-testid="issuable-cancel-button" @click="closeForm">
<gl-button {{ __('Cancel') }}
v-if="shouldShowDeleteButton" </gl-button>
:loading="deleteLoading" </div>
:disabled="deleteLoading" <div v-if="shouldShowDeleteButton">
category="secondary" <gl-button
variant="danger" v-gl-modal="modalId"
class="float-right qa-delete-button" :loading="deleteLoading"
@click="deleteIssuable" :disabled="deleteLoading"
> category="secondary"
{{ deleteIssuableButtonText }} variant="danger"
</gl-button> class="qa-delete-button"
data-testid="issuable-delete-button"
>
{{ deleteIssuableButtonText }}
</gl-button>
<gl-modal
ref="removeModal"
:modal-id="modalId"
size="sm"
:action-primary="modalActionProps.primary"
:action-cancel="modalActionProps.cancel"
@primary="deleteIssuable"
>
<template #modal-title>{{ deleteIssuableButtonText }}</template>
<div>
<p class="gl-mb-1">{{ deleteIssuableModalText }}</p>
</div>
</gl-modal>
</div>
</div> </div>
</template> </template>
...@@ -54,14 +54,14 @@ export default { ...@@ -54,14 +54,14 @@ export default {
<template> <template>
<!-- eslint-disable @gitlab/vue-no-data-toggle --> <!-- eslint-disable @gitlab/vue-no-data-toggle -->
<div class="dropdown js-issuable-selector-wrap" data-issuable-type="issues"> <div class="dropdown js-issuable-selector-wrap gl-mb-0" data-issuable-type="issues">
<button <button
ref="toggle" ref="toggle"
:data-namespace-path="projectNamespace" :data-namespace-path="projectNamespace"
:data-project-path="projectPath" :data-project-path="projectPath"
:data-project-id="projectId" :data-project-id="projectId"
:data-data="issuableTemplatesJson" :data-data="issuableTemplatesJson"
class="dropdown-menu-toggle js-issuable-selector" class="dropdown-menu-toggle js-issuable-selector gl-button"
type="button" type="button"
data-field-name="issuable_template" data-field-name="issuable_template"
data-selected="null" data-selected="null"
......
...@@ -20,7 +20,7 @@ export default { ...@@ -20,7 +20,7 @@ export default {
id="issuable-title" id="issuable-title"
ref="input" ref="input"
v-model="formState.title" v-model="formState.title"
class="form-control qa-title-input" class="form-control qa-title-input gl-border-gray-200"
dir="auto" dir="auto"
type="text" type="text"
:placeholder="__('Title')" :placeholder="__('Title')"
......
<script>
import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { capitalize } from 'lodash';
import { __ } from '~/locale';
import { IssuableTypes } from '../../constants';
import getIssueStateQuery from '../../queries/get_issue_state.query.graphql';
import updateIssueStateMutation from '../../queries/update_issue_state.mutation.graphql';
export const i18n = {
label: __('Issue Type'),
};
export default {
i18n,
IssuableTypes,
components: {
GlFormGroup,
GlDropdown,
GlDropdownItem,
},
data() {
return {
issueState: {},
};
},
apollo: {
issueState: {
query: getIssueStateQuery,
},
},
computed: {
dropdownText() {
const {
issueState: { issueType },
} = this;
return capitalize(issueType);
},
},
methods: {
updateIssueType(issueType) {
this.$apollo.mutate({
mutation: updateIssueStateMutation,
variables: {
issueType,
isDirty: true,
},
});
},
},
};
</script>
<template>
<gl-form-group
:label="$options.i18n.label"
label-class="sr-only"
label-for="issuable-type"
class="mb-2 mb-md-0"
>
<gl-dropdown
id="issuable-type"
:aria-labelledby="$options.i18n.label"
:text="dropdownText"
:header-text="$options.i18n.label"
class="gl-w-full"
toggle-class="dropdown-menu-toggle"
>
<gl-dropdown-item
v-for="type in $options.IssuableTypes"
:key="type.value"
:is-checked="issueState.issueType === type.value"
is-check-item
@click="updateIssueType(type.value)"
>
{{ type.text }}
</gl-dropdown-item>
</gl-dropdown>
</gl-form-group>
</template>
...@@ -2,21 +2,24 @@ ...@@ -2,21 +2,24 @@
import { GlAlert } from '@gitlab/ui'; import { GlAlert } from '@gitlab/ui';
import $ from 'jquery'; import $ from 'jquery';
import Autosave from '~/autosave'; import Autosave from '~/autosave';
import { IssuableType } from '~/issue_show/constants';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import editActions from './edit_actions.vue'; import EditActions from './edit_actions.vue';
import descriptionField from './fields/description.vue'; import DescriptionField from './fields/description.vue';
import descriptionTemplate from './fields/description_template.vue'; import DescriptionTemplateField from './fields/description_template.vue';
import titleField from './fields/title.vue'; import IssuableTitleField from './fields/title.vue';
import lockedWarning from './locked_warning.vue'; import IssuableTypeField from './fields/type.vue';
import LockedWarning from './locked_warning.vue';
export default { export default {
components: { components: {
lockedWarning, DescriptionField,
titleField, DescriptionTemplateField,
descriptionField, EditActions,
descriptionTemplate,
editActions,
GlAlert, GlAlert,
IssuableTitleField,
IssuableTypeField,
LockedWarning,
}, },
props: { props: {
canDestroy: { canDestroy: {
...@@ -89,6 +92,9 @@ export default { ...@@ -89,6 +92,9 @@ export default {
showLockedWarning() { showLockedWarning() {
return this.formState.lockedWarningVisible && !this.formState.updateLoading; return this.formState.lockedWarningVisible && !this.formState.updateLoading;
}, },
isIssueType() {
return this.issuableType === IssuableType.Issue;
},
}, },
created() { created() {
eventHub.$on('delete.issuable', this.resetAutosave); eventHub.$on('delete.issuable', this.resetAutosave);
...@@ -162,7 +168,7 @@ export default { ...@@ -162,7 +168,7 @@ export default {
</script> </script>
<template> <template>
<form> <form data-testid="issuable-form">
<locked-warning v-if="showLockedWarning" /> <locked-warning v-if="showLockedWarning" />
<gl-alert <gl-alert
v-if="showOutdatedDescriptionWarning" v-if="showOutdatedDescriptionWarning"
...@@ -179,9 +185,17 @@ export default { ...@@ -179,9 +185,17 @@ export default {
) )
}}</gl-alert }}</gl-alert
> >
<div class="row gl-mb-3">
<div class="col-12">
<issuable-title-field ref="title" :form-state="formState" />
</div>
</div>
<div class="row"> <div class="row">
<div v-if="hasIssuableTemplates" class="col-sm-4 col-lg-3"> <div v-if="isIssueType" class="col-12 col-md-4 pr-md-0">
<description-template <issuable-type-field ref="issue-type" />
</div>
<div v-if="hasIssuableTemplates" class="col-12 col-md-4 pl-md-2">
<description-template-field
:form-state="formState" :form-state="formState"
:issuable-templates="issuableTemplates" :issuable-templates="issuableTemplates"
:project-path="projectPath" :project-path="projectPath"
...@@ -189,14 +203,6 @@ export default { ...@@ -189,14 +203,6 @@ export default {
:project-namespace="projectNamespace" :project-namespace="projectNamespace"
/> />
</div> </div>
<div
:class="{
'col-sm-8 col-lg-9': hasIssuableTemplates,
'col-12': !hasIssuableTemplates,
}"
>
<title-field ref="title" :form-state="formState" :issuable-templates="issuableTemplates" />
</div>
</div> </div>
<description-field <description-field
ref="description" ref="description"
......
...@@ -25,3 +25,14 @@ export const IssueStateEvent = { ...@@ -25,3 +25,14 @@ export const IssueStateEvent = {
export const STATUS_PAGE_PUBLISHED = __('Published on status page'); export const STATUS_PAGE_PUBLISHED = __('Published on status page');
export const JOIN_ZOOM_MEETING = __('Join Zoom meeting'); export const JOIN_ZOOM_MEETING = __('Join Zoom meeting');
export const IssuableTypes = [
{ value: 'issue', text: __('Issue') },
{ value: 'incident', text: __('Incident') },
];
export const IssueTypePath = 'issues';
export const IncidentTypePath = 'issues/incident';
export const IncidentType = 'incident';
export const issueState = { issueType: undefined, isDirty: false };
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { defaultClient } from '~/sidebar/graphql';
Vue.use(VueApollo);
export default new VueApollo({
defaultClient,
});
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils'; import { parseBoolean } from '~/lib/utils/common_utils';
import issuableApp from './components/app.vue'; import issuableApp from './components/app.vue';
import incidentTabs from './components/incidents/incident_tabs.vue'; import incidentTabs from './components/incidents/incident_tabs.vue';
import { issueState } from './constants';
Vue.use(VueApollo); import apolloProvider from './graphql';
import getIssueStateQuery from './queries/get_issue_state.query.graphql';
export default function initIssuableApp(issuableData = {}) { export default function initIssuableApp(issuableData = {}) {
const apolloProvider = new VueApollo({ const el = document.getElementById('js-issuable-app');
defaultClient: createDefaultClient(),
if (!el) {
return undefined;
}
apolloProvider.clients.defaultClient.cache.writeQuery({
query: getIssueStateQuery,
data: {
issueState: { ...issueState, issueType: el.dataset.issueType },
},
}); });
const { const {
...@@ -25,7 +33,7 @@ export default function initIssuableApp(issuableData = {}) { ...@@ -25,7 +33,7 @@ export default function initIssuableApp(issuableData = {}) {
const fullPath = `${projectNamespace}/${projectPath}`; const fullPath = `${projectNamespace}/${projectPath}`;
return new Vue({ return new Vue({
el: document.getElementById('js-issuable-app'), el,
apolloProvider, apolloProvider,
components: { components: {
issuableApp, issuableApp,
......
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils'; import { parseBoolean } from '~/lib/utils/common_utils';
import IssuableApp from './components/app.vue'; import IssuableApp from './components/app.vue';
import HeaderActions from './components/header_actions.vue'; import HeaderActions from './components/header_actions.vue';
import { issueState } from './constants';
import apolloProvider from './graphql';
import getIssueStateQuery from './queries/get_issue_state.query.graphql';
const bootstrapApollo = (state = {}) => {
return apolloProvider.clients.defaultClient.cache.writeQuery({
query: getIssueStateQuery,
data: {
issueState: state,
},
});
};
export function initIssuableApp(issuableData, store) { export function initIssuableApp(issuableData, store) {
const el = document.getElementById('js-issuable-app');
if (!el) {
return undefined;
}
bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
return new Vue({ return new Vue({
el: document.getElementById('js-issuable-app'), el,
apolloProvider,
store, store,
computed: { computed: {
...mapGetters(['getNoteableData']), ...mapGetters(['getNoteableData']),
...@@ -33,11 +52,7 @@ export function initIssueHeaderActions(store) { ...@@ -33,11 +52,7 @@ export function initIssueHeaderActions(store) {
return undefined; return undefined;
} }
Vue.use(VueApollo); bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({ return new Vue({
el, el,
......
mutation updateIssueState($issueType: String, $isDirty: Boolean) {
updateIssueState(issueType: $issueType, isDirty: $isDirty) @client
}
import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import produce from 'immer';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import getIssueStateQuery from '~/issue_show/queries/get_issue_state.query.graphql';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import introspectionQueryResultData from './fragmentTypes.json'; import introspectionQueryResultData from './fragmentTypes.json';
...@@ -7,15 +9,24 @@ const fragmentMatcher = new IntrospectionFragmentMatcher({ ...@@ -7,15 +9,24 @@ const fragmentMatcher = new IntrospectionFragmentMatcher({
introspectionQueryResultData, introspectionQueryResultData,
}); });
export const defaultClient = createDefaultClient( const resolvers = {
{}, Mutation: {
{ updateIssueState: (_, { issueType = undefined, isDirty = false }, { cache }) => {
cacheConfig: { const sourceData = cache.readQuery({ query: getIssueStateQuery });
fragmentMatcher, const data = produce(sourceData, (draftData) => {
draftData.issueState = { issueType, isDirty };
});
cache.writeQuery({ query: getIssueStateQuery, data });
}, },
assumeImmutableResults: true,
}, },
); };
export const defaultClient = createDefaultClient(resolvers, {
cacheConfig: {
fragmentMatcher,
},
assumeImmutableResults: true,
});
export const apolloProvider = new VueApollo({ export const apolloProvider = new VueApollo({
defaultClient, defaultClient,
......
...@@ -38,6 +38,7 @@ module Issues ...@@ -38,6 +38,7 @@ module Issues
super super
params.delete(:issue_type) unless issue_type_allowed?(issue) params.delete(:issue_type) unless issue_type_allowed?(issue)
filter_incident_label(issue) if params[:issue_type]
moved_issue = params.delete(:moved_issue) moved_issue = params.delete(:moved_issue)
...@@ -82,6 +83,37 @@ module Issues ...@@ -82,6 +83,37 @@ module Issues
def issue_type_allowed?(object) def issue_type_allowed?(object)
can?(current_user, :"create_#{params[:issue_type]}", object) can?(current_user, :"create_#{params[:issue_type]}", object)
end end
# @param issue [Issue]
def filter_incident_label(issue)
return unless add_incident_label?(issue) || remove_incident_label?(issue)
label = ::IncidentManagement::CreateIncidentLabelService
.new(project, current_user)
.execute
.payload[:label]
# These(add_label_ids, remove_label_ids) are being added ahead of time
# to be consumed by #process_label_ids, this allows system notes
# to be applied correctly alongside the label updates.
if add_incident_label?(issue)
params[:add_label_ids] ||= []
params[:add_label_ids] << label.id
else
params[:remove_label_ids] ||= []
params[:remove_label_ids] << label.id
end
end
# @param issue [Issue]
def add_incident_label?(issue)
issue.incident?
end
# @param _issue [Issue, nil]
def remove_incident_label?(_issue)
false
end
end end
end end
......
...@@ -34,7 +34,6 @@ module Issues ...@@ -34,7 +34,6 @@ module Issues
# Add new items to Issues::AfterCreateService if they can be performed in Sidekiq # Add new items to Issues::AfterCreateService if they can be performed in Sidekiq
def after_create(issue) def after_create(issue)
add_incident_label(issue)
user_agent_detail_service.create user_agent_detail_service.create
resolve_discussions_with_issue(issue) resolve_discussions_with_issue(issue)
...@@ -56,22 +55,6 @@ module Issues ...@@ -56,22 +55,6 @@ module Issues
def user_agent_detail_service def user_agent_detail_service
UserAgentDetailService.new(@issue, request) UserAgentDetailService.new(@issue, request)
end end
# Applies label "incident" (creates it if missing) to incident issues.
# For use in "after" hooks only to ensure we are not appyling
# labels prematurely.
def add_incident_label(issue)
return unless issue.incident?
label = ::IncidentManagement::CreateIncidentLabelService
.new(project, current_user)
.execute
.payload[:label]
return if issue.label_ids.include?(label.id)
issue.labels << label
end
end end
end end
......
...@@ -204,6 +204,16 @@ module Issues ...@@ -204,6 +204,16 @@ module Issues
def create_confidentiality_note(issue) def create_confidentiality_note(issue)
SystemNoteService.change_issue_confidentiality(issue, issue.project, current_user) SystemNoteService.change_issue_confidentiality(issue, issue.project, current_user)
end end
override :add_incident_label?
def add_incident_label?(issue)
issue.issue_type != params[:issue_type] && !issue.incident?
end
override :remove_incident_label?
def remove_incident_label?(issue)
issue.issue_type != params[:issue_type] && issue.incident?
end
end end
end end
......
---
title: Allow issue type change for incidents
merge_request: 61363
author:
type: changed
...@@ -326,6 +326,17 @@ In order to change the default issue closing pattern, GitLab administrators must ...@@ -326,6 +326,17 @@ In order to change the default issue closing pattern, GitLab administrators must
[`gitlab.rb` or `gitlab.yml` file](../../../administration/issue_closing_pattern.md) [`gitlab.rb` or `gitlab.yml` file](../../../administration/issue_closing_pattern.md)
of your installation. of your installation.
## Change the issue type
Users with [developer permission](../../permissions.md)
can change an issue's type. To do this, edit the issue and select an issue type from the
**Issue type** selector menu:
- [Issue](index.md)
- [Incident](../../../operations/incident_management/index.md)
![Change the issue type](img/issue_type_change_v13_12.png)
## Deleting issues ## Deleting issues
Users with [project owner permission](../../permissions.md) can delete an issue by Users with [project owner permission](../../permissions.md) can delete an issue by
......
...@@ -31,10 +31,10 @@ RSpec.describe 'Delete Epic', :js do ...@@ -31,10 +31,10 @@ RSpec.describe 'Delete Epic', :js do
end end
it 'deletes the issue and redirect to epic list' do it 'deletes the issue and redirect to epic list' do
page.accept_alert 'Delete this epic and all descendants?' do find('.qa-delete-button').click
find(:button, text: 'Delete epic').click wait_for_requests
end
find('.js-modal-action-primary').click
wait_for_requests wait_for_requests
expect(find('.issuable-list')).not_to have_content(epic.title) expect(find('.issuable-list')).not_to have_content(epic.title)
......
...@@ -18230,6 +18230,9 @@ msgstr "" ...@@ -18230,6 +18230,9 @@ msgstr ""
msgid "Issue Boards" msgid "Issue Boards"
msgstr "" msgstr ""
msgid "Issue Type"
msgstr ""
msgid "Issue already promoted to epic." msgid "Issue already promoted to epic."
msgstr "" msgstr ""
......
...@@ -49,4 +49,42 @@ RSpec.describe 'Incident details', :js do ...@@ -49,4 +49,42 @@ RSpec.describe 'Incident details', :js do
end end
end end
end end
context 'when an incident `issue_type` is edited by a signed in user' do
it 'routes the user to the incident details page when the `issue_type` is set to incident' do
wait_for_requests
project_path = "/#{project.full_path}"
click_button 'Edit title and description'
wait_for_requests
page.within('[data-testid="issuable-form"]') do
click_button 'Incident'
click_button 'Issue'
click_button 'Save changes'
wait_for_requests
expect(page).to have_current_path("#{project_path}/-/issues/#{incident.iid}")
end
end
end
context 'when incident details are edited by a signed in user' do
it 'routes the user to the incident details page when the `issue_type` is set to incident' do
wait_for_requests
project_path = "/#{project.full_path}"
click_button 'Edit title and description'
wait_for_requests
page.within('[data-testid="issuable-form"]') do
click_button 'Incident'
click_button 'Issue'
click_button 'Save changes'
wait_for_requests
expect(page).to have_current_path("#{project_path}/-/issues/#{incident.iid}")
end
end
end
end end
...@@ -6,6 +6,7 @@ RSpec.describe 'Issue Detail', :js do ...@@ -6,6 +6,7 @@ RSpec.describe 'Issue Detail', :js do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project) { create(:project, :public) } let(:project) { create(:project, :public) }
let(:issue) { create(:issue, project: project, author: user) } let(:issue) { create(:issue, project: project, author: user) }
let(:incident) { create(:incident, project: project, author: user) }
context 'when user displays the issue' do context 'when user displays the issue' do
before do before do
...@@ -21,10 +22,8 @@ RSpec.describe 'Issue Detail', :js do ...@@ -21,10 +22,8 @@ RSpec.describe 'Issue Detail', :js do
end end
context 'when user displays the issue as an incident' do context 'when user displays the issue as an incident' do
let(:issue) { create(:incident, project: project, author: user) }
before do before do
visit project_issue_path(project, issue) visit project_issue_path(project, incident)
wait_for_requests wait_for_requests
end end
...@@ -58,9 +57,9 @@ RSpec.describe 'Issue Detail', :js do ...@@ -58,9 +57,9 @@ RSpec.describe 'Issue Detail', :js do
visit project_issue_path(project, issue) visit project_issue_path(project, issue)
wait_for_requests wait_for_requests
page.find('.js-issuable-edit').click click_button 'Edit title and description'
fill_in 'issuable-title', with: 'issue title' fill_in 'issuable-title', with: 'issue title'
click_button 'Save' click_button 'Save changes'
wait_for_requests wait_for_requests
Users::DestroyService.new(user).execute(user) Users::DestroyService.new(user).execute(user)
...@@ -74,4 +73,58 @@ RSpec.describe 'Issue Detail', :js do ...@@ -74,4 +73,58 @@ RSpec.describe 'Issue Detail', :js do
end end
end end
end end
describe 'user updates `issue_type` via the issue type dropdown' do
context 'when an issue `issue_type` is edited by a signed in user' do
before do
sign_in(user)
visit project_issue_path(project, issue)
wait_for_requests
end
it 'routes the user to the incident details page when the `issue_type` is set to incident' do
open_issue_edit_form
page.within('[data-testid="issuable-form"]') do
update_type_select('Issue', 'Incident')
expect(page).to have_current_path(project_issues_incident_path(project, issue))
end
end
end
context 'when an incident `issue_type` is edited by a signed in user' do
before do
sign_in(user)
visit project_issue_path(project, incident)
wait_for_requests
end
it 'routes the user to the issue details page when the `issue_type` is set to issue' do
open_issue_edit_form
page.within('[data-testid="issuable-form"]') do
update_type_select('Incident', 'Issue')
expect(page).to have_current_path(project_issue_path(project, incident))
end
end
end
end
def update_type_select(from, to)
click_button from
click_button to
click_button 'Save changes'
wait_for_requests
end
def open_issue_edit_form
wait_for_requests
click_button 'Edit title and description'
wait_for_requests
end
end end
import { GlIntersectionObserver } from '@gitlab/ui'; import { GlIntersectionObserver } from '@gitlab/ui';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; import { useMockIntersectionObserver } from 'helpers/mock_dom_observer';
import '~/behaviors/markdown/render_gfm'; import '~/behaviors/markdown/render_gfm';
import IssuableApp from '~/issue_show/components/app.vue'; import IssuableApp from '~/issue_show/components/app.vue';
...@@ -17,7 +18,7 @@ import { ...@@ -17,7 +18,7 @@ import {
publishedIncidentUrl, publishedIncidentUrl,
secondRequest, secondRequest,
zoomMeetingUrl, zoomMeetingUrl,
} from '../mock_data'; } from '../mock_data/mock_data';
function formatText(text) { function formatText(text) {
return text.trim().replace(/\s\s+/g, ' '); return text.trim().replace(/\s\s+/g, ' ');
...@@ -36,12 +37,11 @@ describe('Issuable output', () => { ...@@ -36,12 +37,11 @@ describe('Issuable output', () => {
let wrapper; let wrapper;
const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]'); const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]');
const findLockedBadge = () => wrapper.find('[data-testid="locked"]'); const findLockedBadge = () => wrapper.find('[data-testid="locked"]');
const findConfidentialBadge = () => wrapper.find('[data-testid="confidential"]'); const findConfidentialBadge = () => wrapper.find('[data-testid="confidential"]');
const findAlert = () => wrapper.find('.alert');
const mountComponent = (props = {}, options = {}) => { const mountComponent = (props = {}, options = {}, data = {}) => {
wrapper = mount(IssuableApp, { wrapper = mount(IssuableApp, {
propsData: { ...appProps, ...props }, propsData: { ...appProps, ...props },
provide: { provide: {
...@@ -53,6 +53,11 @@ describe('Issuable output', () => { ...@@ -53,6 +53,11 @@ describe('Issuable output', () => {
HighlightBar: true, HighlightBar: true,
IncidentTabs: true, IncidentTabs: true,
}, },
data() {
return {
...data,
};
},
...options, ...options,
}); });
}; };
...@@ -91,10 +96,8 @@ describe('Issuable output', () => { ...@@ -91,10 +96,8 @@ describe('Issuable output', () => {
afterEach(() => { afterEach(() => {
mock.restore(); mock.restore();
realtimeRequestCount = 0; realtimeRequestCount = 0;
wrapper.vm.poll.stop(); wrapper.vm.poll.stop();
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
it('should render a title/description/edited and update title/description/edited on update', () => { it('should render a title/description/edited and update title/description/edited on update', () => {
...@@ -115,7 +118,7 @@ describe('Issuable output', () => { ...@@ -115,7 +118,7 @@ describe('Issuable output', () => {
expect(formatText(editedText.text())).toMatch(/Edited[\s\S]+?by Some User/); expect(formatText(editedText.text())).toMatch(/Edited[\s\S]+?by Some User/);
expect(editedText.find('.author-link').attributes('href')).toMatch(/\/some_user$/); expect(editedText.find('.author-link').attributes('href')).toMatch(/\/some_user$/);
expect(editedText.find('time').text()).toBeTruthy(); expect(editedText.find('time').text()).toBeTruthy();
expect(wrapper.vm.state.lock_version).toEqual(1); expect(wrapper.vm.state.lock_version).toBe(initialRequest.lock_version);
}) })
.then(() => { .then(() => {
wrapper.vm.poll.makeRequest(); wrapper.vm.poll.makeRequest();
...@@ -133,7 +136,9 @@ describe('Issuable output', () => { ...@@ -133,7 +136,9 @@ describe('Issuable output', () => {
expect(editedText.find('.author-link').attributes('href')).toMatch(/\/other_user$/); expect(editedText.find('.author-link').attributes('href')).toMatch(/\/other_user$/);
expect(editedText.find('time').text()).toBeTruthy(); expect(editedText.find('time').text()).toBeTruthy();
expect(wrapper.vm.state.lock_version).toEqual(2); // As the lock_version value does not differ from the server,
// we should not see an alert
expect(findAlert().exists()).toBe(false);
}); });
}); });
...@@ -172,7 +177,7 @@ describe('Issuable output', () => { ...@@ -172,7 +177,7 @@ describe('Issuable output', () => {
${'zoomMeetingUrl'} | ${zoomMeetingUrl} ${'zoomMeetingUrl'} | ${zoomMeetingUrl}
${'publishedIncidentUrl'} | ${publishedIncidentUrl} ${'publishedIncidentUrl'} | ${publishedIncidentUrl}
`('sets the $prop correctly on underlying pinned links', ({ prop, value }) => { `('sets the $prop correctly on underlying pinned links', ({ prop, value }) => {
expect(wrapper.vm[prop]).toEqual(value); expect(wrapper.vm[prop]).toBe(value);
expect(wrapper.find(`[data-testid="${prop}"]`).attributes('href')).toBe(value); expect(wrapper.find(`[data-testid="${prop}"]`).attributes('href')).toBe(value);
}); });
}); });
...@@ -374,9 +379,9 @@ describe('Issuable output', () => { ...@@ -374,9 +379,9 @@ describe('Issuable output', () => {
}); });
}) })
.then(() => { .then(() => {
expect(wrapper.vm.formState.lockedWarningVisible).toEqual(true); expect(wrapper.vm.formState.lockedWarningVisible).toBe(true);
expect(wrapper.vm.formState.lock_version).toEqual(1); expect(wrapper.vm.formState.lock_version).toBe(1);
expect(wrapper.find('.alert').exists()).toBe(true); expect(findAlert().exists()).toBe(true);
}); });
}); });
}); });
...@@ -530,7 +535,7 @@ describe('Issuable output', () => { ...@@ -530,7 +535,7 @@ describe('Issuable output', () => {
`('$title', async ({ state }) => { `('$title', async ({ state }) => {
wrapper.setProps({ issuableStatus: state }); wrapper.setProps({ issuableStatus: state });
await wrapper.vm.$nextTick(); await nextTick();
expect(findStickyHeader().text()).toContain(IssuableStatusText[state]); expect(findStickyHeader().text()).toContain(IssuableStatusText[state]);
}); });
...@@ -542,7 +547,7 @@ describe('Issuable output', () => { ...@@ -542,7 +547,7 @@ describe('Issuable output', () => {
`('$title', async ({ isConfidential }) => { `('$title', async ({ isConfidential }) => {
wrapper.setProps({ isConfidential }); wrapper.setProps({ isConfidential });
await wrapper.vm.$nextTick(); await nextTick();
expect(findConfidentialBadge().exists()).toBe(isConfidential); expect(findConfidentialBadge().exists()).toBe(isConfidential);
}); });
...@@ -554,7 +559,7 @@ describe('Issuable output', () => { ...@@ -554,7 +559,7 @@ describe('Issuable output', () => {
`('$title', async ({ isLocked }) => { `('$title', async ({ isLocked }) => {
wrapper.setProps({ isLocked }); wrapper.setProps({ isLocked });
await wrapper.vm.$nextTick(); await nextTick();
expect(findLockedBadge().exists()).toBe(isLocked); expect(findLockedBadge().exists()).toBe(isLocked);
}); });
...@@ -562,9 +567,9 @@ describe('Issuable output', () => { ...@@ -562,9 +567,9 @@ describe('Issuable output', () => {
}); });
describe('Composable description component', () => { describe('Composable description component', () => {
const findIncidentTabs = () => wrapper.find(IncidentTabs); const findIncidentTabs = () => wrapper.findComponent(IncidentTabs);
const findDescriptionComponent = () => wrapper.find(DescriptionComponent); const findDescriptionComponent = () => wrapper.findComponent(DescriptionComponent);
const findPinnedLinks = () => wrapper.find(PinnedLinks); const findPinnedLinks = () => wrapper.findComponent(PinnedLinks);
const borderClass = 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6'; const borderClass = 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6';
describe('when using description component', () => { describe('when using description component', () => {
......
...@@ -5,7 +5,7 @@ import { TEST_HOST } from 'helpers/test_constants'; ...@@ -5,7 +5,7 @@ import { TEST_HOST } from 'helpers/test_constants';
import mountComponent from 'helpers/vue_mount_component_helper'; import mountComponent from 'helpers/vue_mount_component_helper';
import Description from '~/issue_show/components/description.vue'; import Description from '~/issue_show/components/description.vue';
import TaskList from '~/task_list'; import TaskList from '~/task_list';
import { descriptionProps as props } from '../mock_data'; import { descriptionProps as props } from '../mock_data/mock_data';
jest.mock('~/task_list'); jest.mock('~/task_list');
......
import Vue from 'vue'; import { GlButton, GlModal } from '@gitlab/ui';
import editActions from '~/issue_show/components/edit_actions.vue'; import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import IssuableEditActions from '~/issue_show/components/edit_actions.vue';
import eventHub from '~/issue_show/event_hub'; import eventHub from '~/issue_show/event_hub';
import Store from '~/issue_show/stores';
describe('Edit Actions components', () => { import {
let vm; getIssueStateQueryResponse,
updateIssueStateQueryResponse,
} from '../mock_data/apollo_mock';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Edit Actions component', () => {
let wrapper;
let fakeApollo;
let mockIssueStateData;
const mockResolvers = {
Query: {
issueState() {
return {
__typename: 'IssueState',
rawData: mockIssueStateData(),
};
},
},
};
beforeEach((done) => { const modalId = 'delete-issuable-modal-1';
const Component = Vue.extend(editActions);
const store = new Store({
titleHtml: '',
descriptionHtml: '',
issuableRef: '',
});
store.formState.title = 'test';
jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); const createComponent = ({ props, data } = {}) => {
fakeApollo = createMockApollo([], mockResolvers);
vm = new Component({ wrapper = shallowMountExtended(IssuableEditActions, {
apolloProvider: fakeApollo,
propsData: { propsData: {
formState: {
title: 'GitLab Issue',
},
canDestroy: true, canDestroy: true,
formState: store.formState,
issuableType: 'issue', issuableType: 'issue',
...props,
}, },
}).$mount(); data() {
return {
issueState: {},
modalId,
...data,
};
},
});
};
Vue.nextTick(done); async function deleteIssuable(localWrapper) {
}); localWrapper.findComponent(GlModal).vm.$emit('primary');
}
it('renders all buttons as enabled', () => { const findModal = () => wrapper.findComponent(GlModal);
expect(vm.$el.querySelectorAll('.disabled').length).toBe(0); const findEditButtons = () => wrapper.findAllComponents(GlButton);
const findDeleteButton = () => wrapper.findByTestId('issuable-delete-button');
const findSaveButton = () => wrapper.findByTestId('issuable-save-button');
const findCancelButton = () => wrapper.findByTestId('issuable-cancel-button');
expect(vm.$el.querySelectorAll('[disabled]').length).toBe(0); beforeEach(() => {
mockIssueStateData = jest.fn();
createComponent();
}); });
it('does not render delete button if canUpdate is false', (done) => { afterEach(() => {
vm.canDestroy = false; wrapper.destroy();
});
Vue.nextTick(() => {
expect(vm.$el.querySelector('.btn-danger')).toBeNull();
done(); it('renders all buttons as enabled', () => {
const buttons = findEditButtons().wrappers;
buttons.forEach((button) => {
expect(button.attributes('disabled')).toBeFalsy();
}); });
}); });
it('disables submit button when title is blank', (done) => { it('does not render the delete button if canDestroy is false', () => {
vm.formState.title = ''; createComponent({ props: { canDestroy: false } });
expect(findDeleteButton().exists()).toBe(false);
});
Vue.nextTick(() => { it('disables save button when title is blank', () => {
expect(vm.$el.querySelector('.btn-confirm').getAttribute('disabled')).toBe('disabled'); createComponent({ props: { formState: { title: '', issue_type: '' } } });
done(); expect(findSaveButton().attributes('disabled')).toBe('true');
});
}); });
it('should not show delete button if showDeleteButton is false', (done) => { it('does not render the delete button if showDeleteButton is false', () => {
vm.showDeleteButton = false; createComponent({ props: { showDeleteButton: false } });
Vue.nextTick(() => { expect(findDeleteButton().exists()).toBe(false);
expect(vm.$el.querySelector('.btn-danger')).toBeNull();
done();
});
}); });
describe('updateIssuable', () => { describe('updateIssuable', () => {
it('sends update.issauble event when clicking save button', () => { beforeEach(() => {
vm.$el.querySelector('.btn-confirm').click(); jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable');
}); });
it('disabled button after clicking save button', (done) => { it('sends update.issauble event when clicking save button', () => {
vm.$el.querySelector('.btn-confirm').click(); findSaveButton().vm.$emit('click', { preventDefault: jest.fn() });
Vue.nextTick(() => {
expect(vm.$el.querySelector('.btn-confirm').getAttribute('disabled')).toBe('disabled');
done(); expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable');
});
}); });
}); });
describe('closeForm', () => { describe('closeForm', () => {
beforeEach(() => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
});
it('emits close.form when clicking cancel', () => { it('emits close.form when clicking cancel', () => {
vm.$el.querySelector('.btn-default').click(); findCancelButton().vm.$emit('click');
expect(eventHub.$emit).toHaveBeenCalledWith('close.form'); expect(eventHub.$emit).toHaveBeenCalledWith('close.form');
}); });
}); });
describe('deleteIssuable', () => { describe('renders create modal with the correct information', () => {
it('sends delete.issuable event when clicking save button', () => { it('renders correct modal id', () => {
jest.spyOn(window, 'confirm').mockReturnValue(true); expect(findModal().attributes('modalid')).toBe(modalId);
vm.$el.querySelector('.btn-danger').click(); });
});
expect(eventHub.$emit).toHaveBeenCalledWith('delete.issuable', { destroy_confirm: true }); describe('deleteIssuable', () => {
beforeEach(() => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
}); });
it('does no actions when confirm is false', (done) => { it('does not send the `delete.issuable` event when clicking delete button', () => {
jest.spyOn(window, 'confirm').mockReturnValue(false); findDeleteButton().vm.$emit('click');
vm.$el.querySelector('.btn-danger').click(); expect(eventHub.$emit).not.toHaveBeenCalled();
});
Vue.nextTick(() => { it('sends the `delete.issuable` event when clicking the delete confirm button', async () => {
expect(eventHub.$emit).not.toHaveBeenCalledWith('delete.issuable'); expect(eventHub.$emit).toHaveBeenCalledTimes(0);
await deleteIssuable(wrapper);
expect(eventHub.$emit).toHaveBeenCalledWith('delete.issuable', { destroy_confirm: true });
expect(eventHub.$emit).toHaveBeenCalledTimes(1);
});
});
expect(vm.$el.querySelector('.btn-danger .fa')).toBeNull(); describe('with Apollo cache mock', () => {
it('renders the right delete button text per apollo cache type', async () => {
mockIssueStateData.mockResolvedValue(getIssueStateQueryResponse);
await waitForPromises();
expect(findDeleteButton().text()).toBe('Delete issue');
});
done(); it('should not change the delete button text per apollo cache mutation', async () => {
}); mockIssueStateData.mockResolvedValue(updateIssueStateQueryResponse);
await waitForPromises();
expect(findDeleteButton().text()).toBe('Delete issue');
}); });
}); });
}); });
import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import IssueTypeField, { i18n } from '~/issue_show/components/fields/type.vue';
import { IssuableTypes } from '~/issue_show/constants';
import {
getIssueStateQueryResponse,
updateIssueStateQueryResponse,
} from '../../mock_data/apollo_mock';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Issue type field component', () => {
let wrapper;
let fakeApollo;
let mockIssueStateData;
const mockResolvers = {
Query: {
issueState() {
return {
__typename: 'IssueState',
rawData: mockIssueStateData(),
};
},
},
Mutation: {
updateIssueState: jest.fn().mockResolvedValue(updateIssueStateQueryResponse),
},
};
const findTypeFromGroup = () => wrapper.findComponent(GlFormGroup);
const findTypeFromDropDown = () => wrapper.findComponent(GlDropdown);
const findTypeFromDropDownItems = () => wrapper.findAllComponents(GlDropdownItem);
const createComponent = ({ data } = {}) => {
fakeApollo = createMockApollo([], mockResolvers);
wrapper = shallowMount(IssueTypeField, {
localVue,
apolloProvider: fakeApollo,
data() {
return {
issueState: {},
...data,
};
},
});
};
beforeEach(() => {
mockIssueStateData = jest.fn();
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders a form group with the correct label', () => {
expect(findTypeFromGroup().attributes('label')).toBe(i18n.label);
});
it('renders a form select with the `issue_type` value', () => {
expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.issue);
});
describe('with Apollo cache mock', () => {
it('renders the selected issueType', async () => {
mockIssueStateData.mockResolvedValue(getIssueStateQueryResponse);
await waitForPromises();
expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.issue);
});
it('updates the `issue_type` in the apollo cache when the value is changed', async () => {
findTypeFromDropDownItems().at(1).vm.$emit('click', IssuableTypes.incident);
await wrapper.vm.$nextTick();
expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.incident);
});
});
});
...@@ -2,6 +2,7 @@ import { GlAlert } from '@gitlab/ui'; ...@@ -2,6 +2,7 @@ import { GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import Autosave from '~/autosave'; import Autosave from '~/autosave';
import DescriptionTemplate from '~/issue_show/components/fields/description_template.vue'; import DescriptionTemplate from '~/issue_show/components/fields/description_template.vue';
import IssueTypeField from '~/issue_show/components/fields/type.vue';
import formComponent from '~/issue_show/components/form.vue'; import formComponent from '~/issue_show/components/form.vue';
import LockedWarning from '~/issue_show/components/locked_warning.vue'; import LockedWarning from '~/issue_show/components/locked_warning.vue';
import eventHub from '~/issue_show/event_hub'; import eventHub from '~/issue_show/event_hub';
...@@ -39,6 +40,7 @@ describe('Inline edit form component', () => { ...@@ -39,6 +40,7 @@ describe('Inline edit form component', () => {
}; };
const findDescriptionTemplate = () => wrapper.findComponent(DescriptionTemplate); const findDescriptionTemplate = () => wrapper.findComponent(DescriptionTemplate);
const findIssuableTypeField = () => wrapper.findComponent(IssueTypeField);
const findLockedWarning = () => wrapper.findComponent(LockedWarning); const findLockedWarning = () => wrapper.findComponent(LockedWarning);
const findAlert = () => wrapper.findComponent(GlAlert); const findAlert = () => wrapper.findComponent(GlAlert);
...@@ -68,6 +70,21 @@ describe('Inline edit form component', () => { ...@@ -68,6 +70,21 @@ describe('Inline edit form component', () => {
expect(findDescriptionTemplate().exists()).toBe(true); expect(findDescriptionTemplate().exists()).toBe(true);
}); });
it.each`
issuableType | value
${'issue'} | ${true}
${'epic'} | ${false}
`(
'when `issue_type` is set to "$issuableType" rendering the type select will be "$value"',
({ issuableType, value }) => {
createComponent({
issuableType,
});
expect(findIssuableTypeField().exists()).toBe(value);
},
);
it('hides locked warning by default', () => { it('hides locked warning by default', () => {
createComponent(); createComponent();
......
...@@ -9,7 +9,7 @@ import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue'; ...@@ -9,7 +9,7 @@ import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue';
import INVALID_URL from '~/lib/utils/invalid_url'; import INVALID_URL from '~/lib/utils/invalid_url';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import { descriptionProps } from '../../mock_data'; import { descriptionProps } from '../../mock_data/mock_data';
const mockAlert = { const mockAlert = {
__typename: 'AlertManagementAlert', __typename: 'AlertManagementAlert',
......
...@@ -5,7 +5,7 @@ import { initIssuableApp } from '~/issue_show/issue'; ...@@ -5,7 +5,7 @@ import { initIssuableApp } from '~/issue_show/issue';
import * as parseData from '~/issue_show/utils/parse_data'; import * as parseData from '~/issue_show/utils/parse_data';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import createStore from '~/notes/stores'; import createStore from '~/notes/stores';
import { appProps } from './mock_data'; import { appProps } from './mock_data/mock_data';
const mock = new MockAdapter(axios); const mock = new MockAdapter(axios);
mock.onGet().reply(200); mock.onGet().reply(200);
......
export const getIssueStateQueryResponse = {
issueType: 'issue',
isDirty: false,
};
export const updateIssueStateQueryResponse = {
issueType: 'incident',
isDirty: true,
};
...@@ -48,6 +48,7 @@ export const appProps = { ...@@ -48,6 +48,7 @@ export const appProps = {
initialDescriptionHtml: 'test', initialDescriptionHtml: 'test',
initialDescriptionText: 'test', initialDescriptionText: 'test',
lockVersion: 1, lockVersion: 1,
issueType: 'issue',
markdownPreviewPath: '/', markdownPreviewPath: '/',
markdownDocsPath: '/', markdownDocsPath: '/',
projectNamespace: '/', projectNamespace: '/',
......
...@@ -78,8 +78,8 @@ RSpec.describe Issues::CreateService do ...@@ -78,8 +78,8 @@ RSpec.describe Issues::CreateService do
opts.merge!(title: '') opts.merge!(title: '')
end end
it 'does not create an incident label prematurely' do it 'does not apply an incident label prematurely' do
expect { subject }.not_to change(Label, :count) expect { subject }.to not_change(LabelLink, :count).and not_change(Issue, :count)
end end
end end
end end
......
...@@ -158,6 +158,90 @@ RSpec.describe Issues::UpdateService, :mailer do ...@@ -158,6 +158,90 @@ RSpec.describe Issues::UpdateService, :mailer do
end end
end end
context 'changing issue_type' do
let!(:label_1) { create(:label, project: project, title: 'incident') }
let!(:label_2) { create(:label, project: project, title: 'missed-sla') }
before do
stub_licensed_features(quality_management: true)
end
context 'from issue to incident' do
it 'adds a `incident` label if one does not exist' do
expect { update_issue(issue_type: 'incident') }.to change(issue.labels, :count).by(1)
expect(issue.labels.pluck(:title)).to eq(['incident'])
end
context 'for an issue with multiple labels' do
let(:issue) { create(:incident, project: project, labels: [label_1]) }
before do
update_issue(issue_type: 'incident')
end
it 'does not add an `incident` label if one already exist' do
expect(issue.labels).to eq([label_1])
end
end
context 'filtering the incident label' do
let(:params) { { add_label_ids: [] } }
before do
update_issue(issue_type: 'incident')
end
it 'creates and add a incident label id to add_label_ids' do
expect(issue.label_ids).to contain_exactly(label_1.id)
end
end
end
context 'from incident to issue' do
let(:issue) { create(:incident, project: project) }
context 'for an incident with multiple labels' do
let(:issue) { create(:incident, project: project, labels: [label_1, label_2]) }
before do
update_issue(issue_type: 'issue')
end
it 'removes an `incident` label if one exists on the incident' do
expect(issue.labels).to eq([label_2])
end
end
context 'filtering the incident label' do
let(:issue) { create(:incident, project: project, labels: [label_1, label_2]) }
let(:params) { { label_ids: [label_1.id, label_2.id], remove_label_ids: [] } }
before do
update_issue(issue_type: 'issue')
end
it 'adds an incident label id to remove_label_ids for it to be removed' do
expect(issue.label_ids).to contain_exactly(label_2.id)
end
end
end
context 'from issue to restricted issue types' do
context 'without sufficient permissions' do
let(:user) { create(:user) }
before do
project.add_guest(user)
end
it 'does nothing to the labels' do
expect { update_issue(issue_type: 'issue') }.not_to change(issue.labels, :count)
expect(issue.reload.labels).to eq([])
end
end
end
end
it 'updates open issue counter for assignees when issue is reassigned' do it 'updates open issue counter for assignees when issue is reassigned' do
update_issue(assignee_ids: [user2.id]) update_issue(assignee_ids: [user2.id])
......
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