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';
import Poll from '~/lib/utils/poll';
import { visitUrl } from '~/lib/utils/url_utility';
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 getIssueStateQuery from '../queries/get_issue_state.query.graphql';
import Service from '../services/index';
import Store from '../stores';
import descriptionComponent from './description.vue';
......@@ -195,8 +203,14 @@ export default {
showForm: false,
templatesRequested: false,
isStickyHeaderShowing: false,
issueState: {},
};
},
apollo: {
issueState: {
query: getIssueStateQuery,
},
},
computed: {
issuableTemplates() {
return this.store.formState.issuableTemplates;
......@@ -288,7 +302,7 @@ export default {
methods: {
handleBeforeUnloadEvent(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?');
}
return undefined;
......@@ -346,14 +360,32 @@ export default {
},
updateIssuable() {
const {
store: { formState },
issueState,
} = this;
const issuablePayload = issueState.isDirty
? { ...formState, issue_type: issueState.issueType }
: formState;
this.clearFlash();
return this.service
.updateIssuable(this.store.formState)
.updateIssuable(issuablePayload)
.then((res) => res.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);
}
if (issueState.isDirty) {
const URI =
issueState.issueType === IncidentType
? data.web_url.replace(IssueTypePath, IncidentTypePath)
: data.web_url;
visitUrl(URI);
}
})
.then(this.updateStoreState)
.then(() => {
......
<script>
import { GlButton } from '@gitlab/ui';
import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { __, sprintf } from '~/locale';
import eventHub from '../event_hub';
import updateMixin from '../mixins/update';
import getIssueStateQuery from '../queries/get_issue_state.query.graphql';
const issuableTypes = {
issue: __('Issue'),
epic: __('Epic'),
incident: __('Incident'),
};
export default {
components: {
GlButton,
GlModal,
},
directives: {
GlModal: GlModalDirective,
},
mixins: [updateMixin],
props: {
......@@ -36,19 +43,56 @@ export default {
data() {
return {
deleteLoading: false,
skipApollo: false,
issueState: {},
modalId: uniqueId('delete-issuable-modal-'),
};
},
apollo: {
issueState: {
query: getIssueStateQuery,
skip() {
return this.skipApollo;
},
result() {
this.skipApollo = true;
},
},
},
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() {
return this.formState.title.trim() !== '';
},
modalActionProps() {
return {
primary: {
text: this.deleteIssuableButtonText,
attributes: [{ variant: 'danger' }, { loading: this.deleteLoading }],
},
cancel: {
text: __('Cancel'),
},
};
},
shouldShowDeleteButton() {
return this.canDestroy && this.showDeleteButton;
},
deleteIssuableButtonText() {
return sprintf(__('Delete %{issuableType}'), {
issuableType: issuableTypes[this.issuableType].toLowerCase(),
});
typeToShow() {
const { issueState, issuableType } = this;
const type = issueState.issueType ?? issuableType;
return issuableTypes[type];
},
},
methods: {
......@@ -56,49 +100,57 @@ export default {
eventHub.$emit('close.form');
},
deleteIssuable() {
const confirmMessage =
this.issuableType === 'epic'
? __('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 });
}
this.deleteLoading = true;
eventHub.$emit('delete.issuable', { destroy_confirm: true });
},
},
};
</script>
<template>
<div class="gl-mt-3 gl-mb-3 clearfix">
<gl-button
:loading="formState.updateLoading"
:disabled="formState.updateLoading || !isSubmitEnabled"
category="primary"
variant="confirm"
class="float-left qa-save-button gl-mr-3"
type="submit"
@click.prevent="updateIssuable"
>
{{ __('Save changes') }}
</gl-button>
<gl-button @click="closeForm">
{{ __('Cancel') }}
</gl-button>
<gl-button
v-if="shouldShowDeleteButton"
:loading="deleteLoading"
:disabled="deleteLoading"
category="secondary"
variant="danger"
class="float-right qa-delete-button"
@click="deleteIssuable"
>
{{ deleteIssuableButtonText }}
</gl-button>
<div class="gl-mt-3 gl-mb-3 gl-display-flex gl-justify-content-space-between">
<div>
<gl-button
:loading="formState.updateLoading"
:disabled="formState.updateLoading || !isSubmitEnabled"
category="primary"
variant="confirm"
class="qa-save-button gl-mr-3"
data-testid="issuable-save-button"
type="submit"
@click.prevent="updateIssuable"
>
{{ __('Save changes') }}
</gl-button>
<gl-button data-testid="issuable-cancel-button" @click="closeForm">
{{ __('Cancel') }}
</gl-button>
</div>
<div v-if="shouldShowDeleteButton">
<gl-button
v-gl-modal="modalId"
:loading="deleteLoading"
:disabled="deleteLoading"
category="secondary"
variant="danger"
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>
</template>
......@@ -54,14 +54,14 @@ export default {
<template>
<!-- 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
ref="toggle"
:data-namespace-path="projectNamespace"
:data-project-path="projectPath"
:data-project-id="projectId"
:data-data="issuableTemplatesJson"
class="dropdown-menu-toggle js-issuable-selector"
class="dropdown-menu-toggle js-issuable-selector gl-button"
type="button"
data-field-name="issuable_template"
data-selected="null"
......
......@@ -20,7 +20,7 @@ export default {
id="issuable-title"
ref="input"
v-model="formState.title"
class="form-control qa-title-input"
class="form-control qa-title-input gl-border-gray-200"
dir="auto"
type="text"
: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 @@
import { GlAlert } from '@gitlab/ui';
import $ from 'jquery';
import Autosave from '~/autosave';
import { IssuableType } from '~/issue_show/constants';
import eventHub from '../event_hub';
import editActions from './edit_actions.vue';
import descriptionField from './fields/description.vue';
import descriptionTemplate from './fields/description_template.vue';
import titleField from './fields/title.vue';
import lockedWarning from './locked_warning.vue';
import EditActions from './edit_actions.vue';
import DescriptionField from './fields/description.vue';
import DescriptionTemplateField from './fields/description_template.vue';
import IssuableTitleField from './fields/title.vue';
import IssuableTypeField from './fields/type.vue';
import LockedWarning from './locked_warning.vue';
export default {
components: {
lockedWarning,
titleField,
descriptionField,
descriptionTemplate,
editActions,
DescriptionField,
DescriptionTemplateField,
EditActions,
GlAlert,
IssuableTitleField,
IssuableTypeField,
LockedWarning,
},
props: {
canDestroy: {
......@@ -89,6 +92,9 @@ export default {
showLockedWarning() {
return this.formState.lockedWarningVisible && !this.formState.updateLoading;
},
isIssueType() {
return this.issuableType === IssuableType.Issue;
},
},
created() {
eventHub.$on('delete.issuable', this.resetAutosave);
......@@ -162,7 +168,7 @@ export default {
</script>
<template>
<form>
<form data-testid="issuable-form">
<locked-warning v-if="showLockedWarning" />
<gl-alert
v-if="showOutdatedDescriptionWarning"
......@@ -179,9 +185,17 @@ export default {
)
}}</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 v-if="hasIssuableTemplates" class="col-sm-4 col-lg-3">
<description-template
<div v-if="isIssueType" class="col-12 col-md-4 pr-md-0">
<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"
:issuable-templates="issuableTemplates"
:project-path="projectPath"
......@@ -189,14 +203,6 @@ export default {
:project-namespace="projectNamespace"
/>
</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>
<description-field
ref="description"
......
......@@ -25,3 +25,14 @@ export const IssueStateEvent = {
export const STATUS_PAGE_PUBLISHED = __('Published on status page');
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 VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import issuableApp from './components/app.vue';
import incidentTabs from './components/incidents/incident_tabs.vue';
Vue.use(VueApollo);
import { issueState } from './constants';
import apolloProvider from './graphql';
import getIssueStateQuery from './queries/get_issue_state.query.graphql';
export default function initIssuableApp(issuableData = {}) {
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
const el = document.getElementById('js-issuable-app');
if (!el) {
return undefined;
}
apolloProvider.clients.defaultClient.cache.writeQuery({
query: getIssueStateQuery,
data: {
issueState: { ...issueState, issueType: el.dataset.issueType },
},
});
const {
......@@ -25,7 +33,7 @@ export default function initIssuableApp(issuableData = {}) {
const fullPath = `${projectNamespace}/${projectPath}`;
return new Vue({
el: document.getElementById('js-issuable-app'),
el,
apolloProvider,
components: {
issuableApp,
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { mapGetters } from 'vuex';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import IssuableApp from './components/app.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) {
const el = document.getElementById('js-issuable-app');
if (!el) {
return undefined;
}
bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
return new Vue({
el: document.getElementById('js-issuable-app'),
el,
apolloProvider,
store,
computed: {
...mapGetters(['getNoteableData']),
......@@ -33,11 +52,7 @@ export function initIssueHeaderActions(store) {
return undefined;
}
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
return new Vue({
el,
......
mutation updateIssueState($issueType: String, $isDirty: Boolean) {
updateIssueState(issueType: $issueType, isDirty: $isDirty) @client
}
import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import produce from 'immer';
import VueApollo from 'vue-apollo';
import getIssueStateQuery from '~/issue_show/queries/get_issue_state.query.graphql';
import createDefaultClient from '~/lib/graphql';
import introspectionQueryResultData from './fragmentTypes.json';
......@@ -7,15 +9,24 @@ const fragmentMatcher = new IntrospectionFragmentMatcher({
introspectionQueryResultData,
});
export const defaultClient = createDefaultClient(
{},
{
cacheConfig: {
fragmentMatcher,
const resolvers = {
Mutation: {
updateIssueState: (_, { issueType = undefined, isDirty = false }, { cache }) => {
const sourceData = cache.readQuery({ query: getIssueStateQuery });
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({
defaultClient,
......
......@@ -38,6 +38,7 @@ module Issues
super
params.delete(:issue_type) unless issue_type_allowed?(issue)
filter_incident_label(issue) if params[:issue_type]
moved_issue = params.delete(:moved_issue)
......@@ -82,6 +83,37 @@ module Issues
def issue_type_allowed?(object)
can?(current_user, :"create_#{params[:issue_type]}", object)
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
......
......@@ -34,7 +34,6 @@ module Issues
# Add new items to Issues::AfterCreateService if they can be performed in Sidekiq
def after_create(issue)
add_incident_label(issue)
user_agent_detail_service.create
resolve_discussions_with_issue(issue)
......@@ -56,22 +55,6 @@ module Issues
def user_agent_detail_service
UserAgentDetailService.new(@issue, request)
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
......
......@@ -204,6 +204,16 @@ module Issues
def create_confidentiality_note(issue)
SystemNoteService.change_issue_confidentiality(issue, issue.project, current_user)
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
......
---
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
[`gitlab.rb` or `gitlab.yml` file](../../../administration/issue_closing_pattern.md)
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
Users with [project owner permission](../../permissions.md) can delete an issue by
......
......@@ -31,10 +31,10 @@ RSpec.describe 'Delete Epic', :js do
end
it 'deletes the issue and redirect to epic list' do
page.accept_alert 'Delete this epic and all descendants?' do
find(:button, text: 'Delete epic').click
end
find('.qa-delete-button').click
wait_for_requests
find('.js-modal-action-primary').click
wait_for_requests
expect(find('.issuable-list')).not_to have_content(epic.title)
......
......@@ -18230,6 +18230,9 @@ msgstr ""
msgid "Issue Boards"
msgstr ""
msgid "Issue Type"
msgstr ""
msgid "Issue already promoted to epic."
msgstr ""
......
......@@ -49,4 +49,42 @@ RSpec.describe 'Incident details', :js do
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
......@@ -6,6 +6,7 @@ RSpec.describe 'Issue Detail', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let(:issue) { create(:issue, project: project, author: user) }
let(:incident) { create(:incident, project: project, author: user) }
context 'when user displays the issue' do
before do
......@@ -21,10 +22,8 @@ RSpec.describe 'Issue Detail', :js do
end
context 'when user displays the issue as an incident' do
let(:issue) { create(:incident, project: project, author: user) }
before do
visit project_issue_path(project, issue)
visit project_issue_path(project, incident)
wait_for_requests
end
......@@ -58,9 +57,9 @@ RSpec.describe 'Issue Detail', :js do
visit project_issue_path(project, issue)
wait_for_requests
page.find('.js-issuable-edit').click
click_button 'Edit title and description'
fill_in 'issuable-title', with: 'issue title'
click_button 'Save'
click_button 'Save changes'
wait_for_requests
Users::DestroyService.new(user).execute(user)
......@@ -74,4 +73,58 @@ RSpec.describe 'Issue Detail', :js do
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
import { GlIntersectionObserver } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import { useMockIntersectionObserver } from 'helpers/mock_dom_observer';
import '~/behaviors/markdown/render_gfm';
import IssuableApp from '~/issue_show/components/app.vue';
......@@ -17,7 +18,7 @@ import {
publishedIncidentUrl,
secondRequest,
zoomMeetingUrl,
} from '../mock_data';
} from '../mock_data/mock_data';
function formatText(text) {
return text.trim().replace(/\s\s+/g, ' ');
......@@ -36,12 +37,11 @@ describe('Issuable output', () => {
let wrapper;
const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]');
const findLockedBadge = () => wrapper.find('[data-testid="locked"]');
const findConfidentialBadge = () => wrapper.find('[data-testid="confidential"]');
const findAlert = () => wrapper.find('.alert');
const mountComponent = (props = {}, options = {}) => {
const mountComponent = (props = {}, options = {}, data = {}) => {
wrapper = mount(IssuableApp, {
propsData: { ...appProps, ...props },
provide: {
......@@ -53,6 +53,11 @@ describe('Issuable output', () => {
HighlightBar: true,
IncidentTabs: true,
},
data() {
return {
...data,
};
},
...options,
});
};
......@@ -91,10 +96,8 @@ describe('Issuable output', () => {
afterEach(() => {
mock.restore();
realtimeRequestCount = 0;
wrapper.vm.poll.stop();
wrapper.destroy();
wrapper = null;
});
it('should render a title/description/edited and update title/description/edited on update', () => {
......@@ -115,7 +118,7 @@ describe('Issuable output', () => {
expect(formatText(editedText.text())).toMatch(/Edited[\s\S]+?by Some User/);
expect(editedText.find('.author-link').attributes('href')).toMatch(/\/some_user$/);
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(() => {
wrapper.vm.poll.makeRequest();
......@@ -133,7 +136,9 @@ describe('Issuable output', () => {
expect(editedText.find('.author-link').attributes('href')).toMatch(/\/other_user$/);
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', () => {
${'zoomMeetingUrl'} | ${zoomMeetingUrl}
${'publishedIncidentUrl'} | ${publishedIncidentUrl}
`('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);
});
});
......@@ -374,9 +379,9 @@ describe('Issuable output', () => {
});
})
.then(() => {
expect(wrapper.vm.formState.lockedWarningVisible).toEqual(true);
expect(wrapper.vm.formState.lock_version).toEqual(1);
expect(wrapper.find('.alert').exists()).toBe(true);
expect(wrapper.vm.formState.lockedWarningVisible).toBe(true);
expect(wrapper.vm.formState.lock_version).toBe(1);
expect(findAlert().exists()).toBe(true);
});
});
});
......@@ -530,7 +535,7 @@ describe('Issuable output', () => {
`('$title', async ({ state }) => {
wrapper.setProps({ issuableStatus: state });
await wrapper.vm.$nextTick();
await nextTick();
expect(findStickyHeader().text()).toContain(IssuableStatusText[state]);
});
......@@ -542,7 +547,7 @@ describe('Issuable output', () => {
`('$title', async ({ isConfidential }) => {
wrapper.setProps({ isConfidential });
await wrapper.vm.$nextTick();
await nextTick();
expect(findConfidentialBadge().exists()).toBe(isConfidential);
});
......@@ -554,7 +559,7 @@ describe('Issuable output', () => {
`('$title', async ({ isLocked }) => {
wrapper.setProps({ isLocked });
await wrapper.vm.$nextTick();
await nextTick();
expect(findLockedBadge().exists()).toBe(isLocked);
});
......@@ -562,9 +567,9 @@ describe('Issuable output', () => {
});
describe('Composable description component', () => {
const findIncidentTabs = () => wrapper.find(IncidentTabs);
const findDescriptionComponent = () => wrapper.find(DescriptionComponent);
const findPinnedLinks = () => wrapper.find(PinnedLinks);
const findIncidentTabs = () => wrapper.findComponent(IncidentTabs);
const findDescriptionComponent = () => wrapper.findComponent(DescriptionComponent);
const findPinnedLinks = () => wrapper.findComponent(PinnedLinks);
const borderClass = 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6';
describe('when using description component', () => {
......
......@@ -5,7 +5,7 @@ import { TEST_HOST } from 'helpers/test_constants';
import mountComponent from 'helpers/vue_mount_component_helper';
import Description from '~/issue_show/components/description.vue';
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');
......
import Vue from 'vue';
import editActions from '~/issue_show/components/edit_actions.vue';
import { GlButton, GlModal } from '@gitlab/ui';
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 Store from '~/issue_show/stores';
describe('Edit Actions components', () => {
let vm;
import {
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 Component = Vue.extend(editActions);
const store = new Store({
titleHtml: '',
descriptionHtml: '',
issuableRef: '',
});
store.formState.title = 'test';
const modalId = 'delete-issuable-modal-1';
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
const createComponent = ({ props, data } = {}) => {
fakeApollo = createMockApollo([], mockResolvers);
vm = new Component({
wrapper = shallowMountExtended(IssuableEditActions, {
apolloProvider: fakeApollo,
propsData: {
formState: {
title: 'GitLab Issue',
},
canDestroy: true,
formState: store.formState,
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', () => {
expect(vm.$el.querySelectorAll('.disabled').length).toBe(0);
const findModal = () => wrapper.findComponent(GlModal);
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) => {
vm.canDestroy = false;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.btn-danger')).toBeNull();
afterEach(() => {
wrapper.destroy();
});
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) => {
vm.formState.title = '';
it('does not render the delete button if canDestroy is false', () => {
createComponent({ props: { canDestroy: false } });
expect(findDeleteButton().exists()).toBe(false);
});
Vue.nextTick(() => {
expect(vm.$el.querySelector('.btn-confirm').getAttribute('disabled')).toBe('disabled');
it('disables save button when title is blank', () => {
createComponent({ props: { formState: { title: '', issue_type: '' } } });
done();
});
expect(findSaveButton().attributes('disabled')).toBe('true');
});
it('should not show delete button if showDeleteButton is false', (done) => {
vm.showDeleteButton = false;
it('does not render the delete button if showDeleteButton is false', () => {
createComponent({ props: { showDeleteButton: false } });
Vue.nextTick(() => {
expect(vm.$el.querySelector('.btn-danger')).toBeNull();
done();
});
expect(findDeleteButton().exists()).toBe(false);
});
describe('updateIssuable', () => {
it('sends update.issauble event when clicking save button', () => {
vm.$el.querySelector('.btn-confirm').click();
expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable');
beforeEach(() => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
});
it('disabled button after clicking save button', (done) => {
vm.$el.querySelector('.btn-confirm').click();
Vue.nextTick(() => {
expect(vm.$el.querySelector('.btn-confirm').getAttribute('disabled')).toBe('disabled');
it('sends update.issauble event when clicking save button', () => {
findSaveButton().vm.$emit('click', { preventDefault: jest.fn() });
done();
});
expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable');
});
});
describe('closeForm', () => {
beforeEach(() => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
});
it('emits close.form when clicking cancel', () => {
vm.$el.querySelector('.btn-default').click();
findCancelButton().vm.$emit('click');
expect(eventHub.$emit).toHaveBeenCalledWith('close.form');
});
});
describe('deleteIssuable', () => {
it('sends delete.issuable event when clicking save button', () => {
jest.spyOn(window, 'confirm').mockReturnValue(true);
vm.$el.querySelector('.btn-danger').click();
describe('renders create modal with the correct information', () => {
it('renders correct modal id', () => {
expect(findModal().attributes('modalid')).toBe(modalId);
});
});
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) => {
jest.spyOn(window, 'confirm').mockReturnValue(false);
vm.$el.querySelector('.btn-danger').click();
it('does not send the `delete.issuable` event when clicking delete button', () => {
findDeleteButton().vm.$emit('click');
expect(eventHub.$emit).not.toHaveBeenCalled();
});
Vue.nextTick(() => {
expect(eventHub.$emit).not.toHaveBeenCalledWith('delete.issuable');
it('sends the `delete.issuable` event when clicking the delete confirm button', async () => {
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';
import { shallowMount } from '@vue/test-utils';
import Autosave from '~/autosave';
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 LockedWarning from '~/issue_show/components/locked_warning.vue';
import eventHub from '~/issue_show/event_hub';
......@@ -39,6 +40,7 @@ describe('Inline edit form component', () => {
};
const findDescriptionTemplate = () => wrapper.findComponent(DescriptionTemplate);
const findIssuableTypeField = () => wrapper.findComponent(IssueTypeField);
const findLockedWarning = () => wrapper.findComponent(LockedWarning);
const findAlert = () => wrapper.findComponent(GlAlert);
......@@ -68,6 +70,21 @@ describe('Inline edit form component', () => {
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', () => {
createComponent();
......
......@@ -9,7 +9,7 @@ import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue';
import INVALID_URL from '~/lib/utils/invalid_url';
import Tracking from '~/tracking';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import { descriptionProps } from '../../mock_data';
import { descriptionProps } from '../../mock_data/mock_data';
const mockAlert = {
__typename: 'AlertManagementAlert',
......
......@@ -5,7 +5,7 @@ import { initIssuableApp } from '~/issue_show/issue';
import * as parseData from '~/issue_show/utils/parse_data';
import axios from '~/lib/utils/axios_utils';
import createStore from '~/notes/stores';
import { appProps } from './mock_data';
import { appProps } from './mock_data/mock_data';
const mock = new MockAdapter(axios);
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 = {
initialDescriptionHtml: 'test',
initialDescriptionText: 'test',
lockVersion: 1,
issueType: 'issue',
markdownPreviewPath: '/',
markdownDocsPath: '/',
projectNamespace: '/',
......
......@@ -78,8 +78,8 @@ RSpec.describe Issues::CreateService do
opts.merge!(title: '')
end
it 'does not create an incident label prematurely' do
expect { subject }.not_to change(Label, :count)
it 'does not apply an incident label prematurely' do
expect { subject }.to not_change(LabelLink, :count).and not_change(Issue, :count)
end
end
end
......
......@@ -158,6 +158,90 @@ RSpec.describe Issues::UpdateService, :mailer do
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
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