Commit b0252651 authored by Kushal Pandya's avatar Kushal Pandya Committed by Douglas Barbosa Alexandre

Add support for closing epics

parent f7007ca6
......@@ -293,6 +293,7 @@
:show-delete-button="showDeleteButton"
:can-attach-file="canAttachFile"
:enable-autocomplete="enableAutocomplete"
:issuable-type="issuableType"
/>
<recaptcha-modal
......
<script>
import { __, sprintf } from '~/locale';
import updateMixin from '../mixins/update';
import eventHub from '../event_hub';
const issuableTypes = {
issue: __('Issue'),
epic: __('Epic'),
};
export default {
mixins: [updateMixin],
props: {
......@@ -18,6 +24,10 @@
required: false,
default: true,
},
issuableType: {
type: String,
required: true,
},
},
data() {
return {
......@@ -37,8 +47,11 @@
eventHub.$emit('close.form');
},
deleteIssuable() {
const confirmMessage = sprintf(__('%{issuableType} will be removed! Are you sure?'), {
issuableType: issuableTypes[this.issuableType],
});
// eslint-disable-next-line no-alert
if (window.confirm('Issue will be removed! Are you sure?')) {
if (window.confirm(confirmMessage)) {
this.deleteLoading = true;
eventHub.$emit('delete.issuable');
......
......@@ -27,6 +27,10 @@
required: false,
default: () => [],
},
issuableType: {
type: String,
required: true,
},
markdownPreviewPath: {
type: String,
required: true,
......@@ -110,6 +114,7 @@
:form-state="formState"
:can-destroy="canDestroy"
:show-delete-button="showDeleteButton"
:issuable-type="issuableType"
/>
</form>
</template>
......@@ -5,7 +5,8 @@ module Notes
UPDATE_SERVICES = {
'Issue' => Issues::UpdateService,
'MergeRequest' => MergeRequests::UpdateService,
'Commit' => Commits::TagService
'Commit' => Commits::TagService,
'Epic' => Epics::UpdateService
}.freeze
def self.noteable_update_service(note)
......
......@@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20180917214204) do
ActiveRecord::Schema.define(version: 20180920043317) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
......@@ -973,10 +973,14 @@ ActiveRecord::Schema.define(version: 20180917214204) do
t.date "due_date_fixed"
t.boolean "start_date_is_fixed"
t.boolean "due_date_is_fixed"
t.integer "state", limit: 2, default: 1, null: false
t.integer "closed_by_id"
t.datetime "closed_at"
end
add_index "epics", ["assignee_id"], name: "index_epics_on_assignee_id", using: :btree
add_index "epics", ["author_id"], name: "index_epics_on_author_id", using: :btree
add_index "epics", ["closed_by_id"], name: "index_epics_on_closed_by_id", using: :btree
add_index "epics", ["end_date"], name: "index_epics_on_end_date", using: :btree
add_index "epics", ["group_id"], name: "index_epics_on_group_id", using: :btree
add_index "epics", ["iid"], name: "index_epics_on_iid", using: :btree
......@@ -3123,6 +3127,7 @@ ActiveRecord::Schema.define(version: 20180917214204) do
add_foreign_key "epics", "namespaces", column: "group_id", name: "fk_f081aa4489", on_delete: :cascade
add_foreign_key "epics", "users", column: "assignee_id", name: "fk_dccd3f98fc", on_delete: :nullify
add_foreign_key "epics", "users", column: "author_id", name: "fk_3654b61b03", on_delete: :cascade
add_foreign_key "epics", "users", column: "closed_by_id", name: "fk_aa5798e761", on_delete: :nullify
add_foreign_key "events", "projects", on_delete: :cascade
add_foreign_key "events", "users", column: "author_id", name: "fk_edfd187b6f", on_delete: :cascade
add_foreign_key "fork_network_members", "fork_networks", on_delete: :cascade
......
......@@ -208,6 +208,7 @@ PUT /groups/:id/epics/:epic_iid
| `start_date_fixed` | string | no | The fixed start date of an epic (since 11.3) |
| `due_date_is_fixed` | boolean | no | Whether due date should be sourced from `due_date_fixed` or from milestones (since 11.3) |
| `due_date_fixed` | string | no | The fixed due date of an epic (since 11.3) |
| `state_event` | string | no | State event for an epic. Set `close` to close the epic and `reopen` to reopen it (since 11.4) |
```bash
curl --header PUT "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/1/epics/5?title=New%20Title
......
......@@ -64,6 +64,23 @@ A modal will pop-up to confirm your action.
Deleting an epic releases all existing issues from their associated epic in the
system.
## Closing and reopening epics
### Using buttons
Whenever you decide that there is no longer need for that epic,
close the epic using the close button:
![close epic - button](img/button_close_epic.png)
You can always reopen it using the reopen button.
![reopen epic - button](img/button_reopen_epic.png)
### Using quick actions
You can close or reopen an epic using [Quick actions](../../project/quick_actions.md)
## Navigating to an epic from an issue
If an issue belongs to an epic, you can navigate to the containing epic with the
......
......@@ -12,8 +12,8 @@ do.
| Command | Action |
|:---------------------------|:-------------|
| `/close` | Close the issue or merge request |
| `/reopen` | Reopen the issue or merge request |
| `/close` | Close the issue, merge request or epic |
| `/reopen` | Reopen the issue, merge request or epic |
| `/merge` | Merge (when pipeline succeeds) |
| `/title <New title>` | Change title |
| `/assign @user1 @user2 ` | Add assignee(s) |
......
export const status = {
open: 'opened',
close: 'closed',
};
export const stateEvent = {
close: 'close',
reopen: 'reopen',
};
<script>
import $ from 'jquery';
import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import timeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import Icon from '~/vue_shared/components/icon.vue';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import loadingButton from '~/vue_shared/components/loading_button.vue';
import { s__ } from '~/locale';
import { __, s__ } from '~/locale';
import eventHub from '../../event_hub';
import { stateEvent } from '../../constants';
export default {
name: 'EpicHeader',
......@@ -12,9 +15,10 @@
tooltip,
},
components: {
Icon,
LoadingButton,
userAvatarLink,
timeagoTooltip,
loadingButton,
},
props: {
author: {
......@@ -26,17 +30,42 @@
type: String,
required: true,
},
canDelete: {
open: {
type: Boolean,
required: true,
},
canUpdate: {
required: true,
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
deleteLoading: false,
statusUpdating: false,
isEpicOpen: this.open,
};
},
computed: {
statusIcon() {
return this.isEpicOpen ? 'issue-open-m' : 'mobile-issue-close';
},
statusText() {
return this.isEpicOpen ? __('Open') : __('Closed');
},
actionButtonClass() {
return `btn btn-grouped js-btn-epic-action ${this.isEpicOpen ? 'btn-close' : 'btn-open'}`;
},
actionButtonText() {
return this.isEpicOpen ? __('Close epic') : __('Reopen epic');
},
},
mounted() {
$(document).on('issuable_vue_app:change', (e, isClosed) => {
this.isEpicOpen = e.detail ? !e.detail.isClosed : !isClosed;
this.statusUpdating = false;
});
},
methods: {
deleteEpic() {
if (window.confirm(s__('Epic will be removed! Are you sure?'))) { // eslint-disable-line no-alert
......@@ -47,34 +76,54 @@
toggleSidebar() {
eventHub.$emit('toggleSidebar');
},
toggleStatus() {
this.statusUpdating = true;
this.$emit('toggleEpicStatus', this.isEpicOpen ? stateEvent.close : stateEvent.reopen);
},
},
};
</script>
<template>
<div class="detail-page-header">
<div class="issuable-meta">
{{ s__('Opened') }}
<timeago-tooltip :time="created" />
{{ s__('by') }}
<strong>
<user-avatar-link
:link-href="author.url"
:img-src="author.src"
:img-size="24"
:tooltip-text="author.username"
:username="author.name"
img-css-classes="avatar-inline"
<div class="detail-page-header-body">
<div
:class="{ 'status-box-open': isEpicOpen, 'status-box-issue-closed': !isEpicOpen }"
class="issuable-status-box status-box"
>
<icon
:name="statusIcon"
css-classes="d-block d-sm-none"
/>
</strong>
<span class="d-none d-sm-block">{{ statusText }}</span>
</div>
<div class="issuable-meta">
{{ s__('Opened') }}
<timeago-tooltip :time="created" />
{{ s__('by') }}
<strong>
<user-avatar-link
:link-href="author.url"
:img-src="author.src"
:img-size="24"
:tooltip-text="author.username"
:username="author.name"
img-css-classes="avatar-inline"
/>
</strong>
</div>
</div>
<div
v-if="canUpdate"
class="detail-page-header-actions js-issuable-actions"
>
<loading-button
:label="actionButtonText"
:loading="statusUpdating"
:container-class="actionButtonClass"
@click="toggleStatus"
/>
</div>
<loading-button
v-if="canDelete"
:loading="deleteLoading"
:label="s__('Delete')"
container-class="btn btn-remove btn-inverted flex-right"
@click="deleteEpic"
/>
<button
:aria-label="__('toggle collapse')"
class="btn btn-default float-right d-block d-sm-none
......
<script>
/* eslint-disable vue/require-default-prop */
import $ from 'jquery';
import issuableApp from '~/issue_show/components/app.vue';
import flash from '~/flash';
import { __ } from '~/locale';
import relatedIssuesRoot from 'ee/related_issues/components/related_issues_root.vue';
import issuableAppEventHub from '~/issue_show/event_hub';
import epicSidebar from '../../sidebar/components/sidebar_app.vue';
import SidebarContext from '../sidebar_context';
import epicHeader from './epic_header.vue';
import EpicsService from '../../service/epics_service';
import { status, stateEvent } from '../../constants';
export default {
name: 'EpicShowApp',
......@@ -174,6 +178,11 @@
type: String,
required: true,
},
state: {
type: String,
required: true,
default: status.open,
},
},
data() {
return {
......@@ -181,14 +190,42 @@
issuableRef: '',
projectPath: this.groupPath,
projectNamespace: '',
service: new EpicsService({
endpoint: this.endpoint,
}),
};
},
computed: {
open() {
return this.state === status.open;
},
},
mounted() {
this.sidebarContext = new SidebarContext();
},
methods: {
deleteEpic() {
issuableAppEventHub.$emit('delete.issuable');
triggerDocumentEvent(eventName, isClosed) {
$(document).trigger(eventName, isClosed);
},
toggleEpicStatus(stateEventType) {
return this.service
.updateStatus(stateEventType)
.then(() => {
const isClosed = stateEventType === stateEvent.close;
// Ensure that status change is reflected across the page.
// As `Close`/`Reopen` button is also present under
// comment form (part of Notes app)
// We've wrapped call to `$(document).trigger` for ease of testing
this.triggerDocumentEvent('issuable_vue_app:change', isClosed);
this.triggerDocumentEvent('issuable:change', isClosed);
})
.catch(() => {
flash(__('Unable to update this epic at this time.'));
const isClosed = stateEventType !== stateEvent.close;
this.triggerDocumentEvent('issuable_vue_app:change', isClosed);
this.triggerDocumentEvent('issuable:change', isClosed);
});
},
},
};
......@@ -199,8 +236,10 @@
<epic-header
:author="author"
:created="created"
:open="open"
:can-delete="canDestroy"
@deleteEpic="deleteEpic"
:can-update="canUpdate"
@toggleEpicStatus="toggleEpicStatus"
/>
<div class="issuable-details content-block">
<div class="detail-page-description">
......@@ -219,7 +258,7 @@
:project-path="projectPath"
:project-namespace="projectNamespace"
:show-inline-edit-button="true"
:show-delete-button="false"
:show-delete-button="canDestroy"
:enable-autocomplete="true"
issuable-type="epic"
/>
......
import axios from '~/lib/utils/axios_utils';
export default class EpicsService {
constructor({ endpoint }) {
this.endpoint = endpoint;
}
updateStatus(stateEventType) {
const queryParam = `epic[state_event]=${stateEventType}`;
return axios.put(`${this.endpoint}.json?${encodeURI(queryParam)}`);
}
}
......@@ -35,8 +35,15 @@
}
@include media-breakpoint-down(xs) {
.epic-page-container .detail-page-header {
display: flex;
.epic-page-container {
.detail-page-header {
display: flex;
}
.detail-page-header-actions {
width: auto;
margin-top: 0;
}
}
}
......
......@@ -73,6 +73,7 @@ class Groups::EpicsController < Groups::ApplicationController
:start_date_is_fixed,
:due_date_fixed,
:due_date_is_fixed,
:state_event,
label_ids: []
]
end
......
......@@ -25,7 +25,8 @@ module EpicsHelper
due_date_fixed: epic.due_date_fixed,
due_date_from_milestones: epic.due_date_from_milestones,
due_date_sourcing_milestone_title: epic.due_date_sourcing_milestone&.title,
end_date: epic.end_date
end_date: epic.end_date,
state: epic.state
}
epic_meta[:todo_delete_path] = dashboard_todo_path(todo) if todo.present?
......
......@@ -11,6 +11,22 @@ module EE
include Awardable
include LabelEventable
enum state: { opened: 1, closed: 2 }
belongs_to :closed_by, class_name: 'User'
def reopen
return if opened?
update(state: :opened, closed_at: nil, closed_by: nil)
end
def close
return if closed?
update(state: :closed, closed_at: Time.zone.now)
end
belongs_to :assignee, class_name: "User"
belongs_to :group
belongs_to :start_date_sourcing_milestone, class_name: 'Milestone'
......
......@@ -14,6 +14,7 @@ class EpicEntity < IssuableEntity
expose :end_date, as: :due_date
expose :due_date_is_fixed?, as: :due_date_is_fixed
expose :due_date_fixed, :due_date_from_milestones
expose :state
expose :web_url do |epic|
group_epic_path(epic.group, epic)
......@@ -24,6 +25,10 @@ class EpicEntity < IssuableEntity
expose :can_create_note do |epic|
can?(request.current_user, :create_note, epic)
end
expose :can_update do |epic|
can?(request.current_user, :update_epic, epic)
end
end
expose :create_note_path do |epic|
......
......@@ -2,7 +2,7 @@ module Epics
class BaseService < IssuableBaseService
attr_reader :group
def initialize(group, current_user, params)
def initialize(group, current_user, params = {})
@group, @current_user, @params = group, current_user, params
end
......@@ -20,5 +20,13 @@ module Epics
def parent
group
end
def close_service
Epics::CloseService
end
def reopen_service
Epics::ReopenService
end
end
end
# frozen_string_literal: true
module Epics
class CloseService < Epics::BaseService
def execute(epic)
return epic unless can?(current_user, :update_epic, epic)
close_epic(epic)
end
private
def close_epic(epic)
if epic.close
epic.update(closed_by: current_user)
end
end
end
end
# frozen_string_literal: true
module Epics
class ReopenService < Epics::BaseService
def execute(epic)
return epic unless can?(current_user, :update_epic, epic)
epic.reopen
epic
end
end
end
---
title: Add support for closing epics
merge_request: 7302
author:
type: added
# frozen_string_literal: true
class AddClosedColumnsToEpic < ActiveRecord::Migration
DOWNTIME = false
def up
add_reference :epics, :closed_by, index: true
add_column :epics, :closed_at, :datetime_with_timezone
end
def down
remove_reference :epics, :closed_by, index: true
remove_column :epics, :closed_at
end
end
# frozen_string_literal: true
class AddStateToEpic < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_column_with_default :epics, :state, :integer, limit: 2, default: 1
end
def down
remove_column :epics, :state
end
end
# frozen_string_literal: true
class AddForeignKeyToEpics < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_foreign_key :epics, :users, column: :closed_by_id, on_delete: :nullify
end
def down
remove_foreign_key :epics, column: :closed_by_id
end
end
......@@ -112,7 +112,8 @@ module API
optional :end_date, as: :due_date_fixed, type: String, desc: 'The due date of an epic'
optional :due_date_is_fixed, type: Boolean, desc: 'Indicates due date should be sourced from due_date_fixed field not the issue milestones'
optional :labels, type: String, desc: 'Comma-separated list of label names'
at_least_one_of :title, :description, :start_date_fixed, :start_date_is_fixed, :due_date_fixed, :due_date_is_fixed, :labels
optional :state_event, type: String, values: %w[reopen close], desc: 'State event for an epic'
at_least_one_of :title, :description, :start_date_fixed, :start_date_is_fixed, :due_date_fixed, :due_date_is_fixed, :labels, :state_event
end
put ':id/(-/)epics/:epic_iid' do
authorize_can_admin!
......
......@@ -180,6 +180,7 @@ module EE
expose :end_date, as: :due_date
expose :due_date_is_fixed?, as: :due_date_is_fixed, if: can_admin_epic
expose :due_date_fixed, :due_date_from_milestones, if: can_admin_epic
expose :state
expose :created_at
expose :updated_at
expose :labels do |epic, options|
......
......@@ -225,25 +225,71 @@ describe Groups::EpicsController do
end
describe 'PUT #update' do
let(:date) { Date.new(2002, 1, 1)}
let(:date) { Date.new(2002, 1, 1) }
let(:params) do
{
title: 'New title',
label_ids: [label.id],
start_date_fixed: '2002-01-01',
start_date_is_fixed: true
}
end
before do
group.add_developer(user)
put :update, group_id: group, id: epic.to_param, epic: { title: 'New title', label_ids: [label.id], start_date_fixed: '2002-01-01', start_date_is_fixed: true }, format: :json
end
it 'returns status 200' do
expect(response.status).to eq(200)
context 'with correct basic params' do
it 'returns status 200' do
update_epic(epic, params)
expect(response.status).to eq(200)
end
it 'updates the epic correctly' do
update_epic(epic, params)
expect(epic.reload).to have_attributes(
title: 'New title',
labels: [label],
start_date_fixed: date,
start_date: date,
start_date_is_fixed: true,
state: 'opened'
)
end
end
it 'updates the epic correctly' do
epic.reload
context 'when state_event param is close' do
it 'allows epic to be closed' do
update_epic(epic, params.merge(state_event: 'close'))
epic.reload
expect(epic).to be_closed
expect(epic.closed_at).not_to be_nil
expect(epic.closed_by).to eq(user)
end
end
context 'when state_event param is reopen' do
before do
epic.update!(state: 'closed', closed_at: Time.now, closed_by: user)
end
it 'allows epic to be reopened' do
update_epic(epic, params.merge(state_event: 'reopen'))
epic.reload
expect(epic).to be_opened
expect(epic.closed_at).to be_nil
expect(epic.closed_by).to be_nil
end
end
expect(epic.title).to eq('New title')
expect(epic.labels).to eq([label])
expect(epic.start_date_fixed).to eq(date)
expect(epic.start_date).to eq(date)
expect(epic.start_date_is_fixed).to eq(true)
def update_epic(epic, params)
put :update, group_id: epic.group.to_param, id: epic.to_param, epic: params, format: :json
end
end
......
......@@ -25,11 +25,12 @@ describe 'Delete Epic', :js do
group.add_owner(user)
visit group_epic_path(group, epic)
wait_for_requests
find('.js-issuable-edit').click
end
it 'deletes the issue and redirect to epic list' do
page.accept_alert 'Epic will be removed! Are you sure?' do
find('.detail-page-header button').click
find(:button, text: 'Delete').click
end
wait_for_requests
......
......@@ -139,10 +139,10 @@ describe 'Update Epic', :js do
wait_for_requests
end
it 'does not show delete button inside the edit form' do
it 'shows delete button inside the edit form' do
find('.btn-edit').click
expect(page).not_to have_selector('.issuable-details .btn-danger')
expect(page).to have_selector('.issuable-details .btn-danger')
end
end
end
......@@ -33,6 +33,7 @@
"due_date_fixed": { "type": ["date", "null"] },
"due_date_from_milestones": { "type": ["date", "null"] },
"due_date_is_fixed": { "type": "boolean" },
"state": { "type": "string" },
"created_at": { "type": ["string", "null"] },
"updated_at": { "type": ["string", "null"] }
},
......
......@@ -27,6 +27,7 @@
"due_date_fixed": { "type": ["date", "null"] },
"due_date_from_milestones": { "type": ["date", "null"] },
"due_date_is_fixed": { "type": "boolean" },
"state": { "type": "string" },
"created_at": { "type": ["string", "null"] },
"updated_at": { "type": ["string", "null"] },
"labels": {
......
......@@ -29,7 +29,7 @@ describe EpicsHelper do
expected_keys = %i(initial meta namespace labels_path toggle_subscription_path labels_web_url epics_web_url)
expect(data.keys).to match_array(expected_keys)
expect(meta_data.keys).to match_array(%w[
created author epic_id todo_exists todo_path
created author epic_id todo_exists todo_path state
start_date start_date_fixed start_date_is_fixed start_date_from_milestones start_date_sourcing_milestone_title
end_date due_date due_date_fixed due_date_is_fixed due_date_from_milestones due_date_sourcing_milestone_title
])
......@@ -44,6 +44,40 @@ describe EpicsHelper do
expect(meta_data['due_date']).to eq('2000-01-02')
expect(meta_data['due_date_sourcing_milestone_title']).to eq(milestone.title)
end
context 'when a user can update an epic' do
let(:milestone) { create(:milestone, title: 'make me a sandwich') }
let!(:epic) do
create(
:epic,
author: user,
start_date_sourcing_milestone: milestone,
start_date: Date.new(2000, 1, 1),
due_date_sourcing_milestone: milestone,
due_date: Date.new(2000, 1, 2)
)
end
before do
epic.group.add_developer(user)
end
it 'returns extra date fields' do
data = helper.epic_show_app_data(epic, initial: {}, author_icon: 'icon_path')
meta_data = JSON.parse(data[:meta])
expect(meta_data.keys).to match_array(%w[
created author epic_id todo_exists todo_path state
start_date start_date_fixed start_date_is_fixed start_date_from_milestones start_date_sourcing_milestone_title
end_date due_date due_date_fixed due_date_is_fixed due_date_from_milestones due_date_sourcing_milestone_title
])
expect(meta_data['start_date']).to eq('2000-01-01')
expect(meta_data['start_date_sourcing_milestone_title']).to eq(milestone.title)
expect(meta_data['due_date']).to eq('2000-01-02')
expect(meta_data['due_date_sourcing_milestone_title']).to eq(milestone.title)
end
end
end
describe '#epic_endpoint_query_params' do
......
import Vue from 'vue';
import epicHeader from 'ee/epics/epic_show/components/epic_header.vue';
import { stateEvent } from 'ee/epics/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { headerProps } from '../mock_data';
......@@ -38,41 +39,90 @@ describe('epicHeader', () => {
expect(vm.$el.querySelector('button.js-sidebar-toggle')).not.toBe(null);
});
describe('canDelete', () => {
it('should not show loading button by default', () => {
expect(vm.$el.querySelector('.btn-remove')).toBeNull();
});
it('should render status badge', () => {
const badgeEl = vm.$el.querySelector('.issuable-status-box');
const badgeIconEl = badgeEl.querySelector('svg use');
expect(badgeEl).not.toBe(null);
expect(badgeEl.innerText.trim()).toBe('Open');
expect(badgeIconEl.getAttribute('xlink:href')).toContain('issue-open-m');
});
it('should render `Close epic` button when `isEpicOpen` & `canUpdate` props are true', () => {
vm.isEpicOpen = true;
const closeButtonEl = vm.$el.querySelector('.js-issuable-actions .js-btn-epic-action');
expect(closeButtonEl).not.toBe(null);
expect(closeButtonEl.innerText.trim()).toBe('Close epic');
});
describe('computed', () => {
describe('statusIcon', () => {
it('returns `issue-open-m` when `isEpicOpen` prop is true', () => {
vm.isEpicOpen = true;
expect(vm.statusIcon).toBe('issue-open-m');
});
it('should show loading button if canDelete', done => {
vm.canDelete = true;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.btn-remove')).toBeDefined();
done();
it('returns `mobile-issue-close` when `isEpicOpen` prop is false', () => {
vm.isEpicOpen = false;
expect(vm.statusIcon).toBe('mobile-issue-close');
});
});
});
describe('delete epic', () => {
let deleteEpic;
describe('statusText', () => {
it('returns `Open` when `isEpicOpen` prop is true', () => {
vm.isEpicOpen = true;
expect(vm.statusText).toBe('Open');
});
beforeEach(done => {
deleteEpic = jasmine.createSpy();
spyOn(window, 'confirm').and.returnValue(true);
vm.canDelete = true;
vm.$on('deleteEpic', deleteEpic);
it('returns `Closed` when `isEpicOpen` prop is false', () => {
vm.isEpicOpen = false;
expect(vm.statusText).toBe('Closed');
});
});
Vue.nextTick(() => {
vm.$el.querySelector('.btn-remove').click();
done();
describe('actionButtonClass', () => {
it('returns classes `btn btn-grouped js-btn-epic-action` & `btn-close` when `isEpicOpen` prop is true', () => {
vm.isEpicOpen = true;
expect(vm.actionButtonClass).toContain('btn btn-grouped js-btn-epic-action btn-close');
});
it('returns classes `btn btn-grouped js-btn-epic-action` & `btn-open` when `isEpicOpen` prop is false', () => {
vm.isEpicOpen = false;
expect(vm.actionButtonClass).toContain('btn btn-grouped js-btn-epic-action btn-open');
});
});
it('should set deleteLoading', () => {
expect(vm.deleteLoading).toEqual(true);
describe('actionButtonText', () => {
it('returns `Close epic` when `isEpicOpen` prop is true', () => {
vm.isEpicOpen = true;
expect(vm.actionButtonText).toBe('Close epic');
});
it('returns `Reopen epic` when `isEpicOpen` prop is false', () => {
vm.isEpicOpen = false;
expect(vm.actionButtonText).toBe('Reopen epic');
});
});
});
it('should emit deleteEpic event', () => {
expect(deleteEpic).toHaveBeenCalled();
describe('methods', () => {
describe('toggleStatus', () => {
it('emits `toggleEpicStatus` on component with stateEventType param as `close` when `isEpicOpen` prop is true', () => {
spyOn(vm, '$emit');
vm.isEpicOpen = true;
vm.toggleStatus();
expect(vm.statusUpdating).toBe(true);
expect(vm.$emit).toHaveBeenCalledWith('toggleEpicStatus', stateEvent.close);
});
it('emits `toggleEpicStatus` on component with stateEventType param as `reopen` when `isEpicOpen` prop is false', () => {
spyOn(vm, '$emit');
vm.isEpicOpen = false;
vm.toggleStatus();
expect(vm.statusUpdating).toBe(true);
expect(vm.$emit).toHaveBeenCalledWith('toggleEpicStatus', stateEvent.reopen);
});
});
});
});
......@@ -3,13 +3,14 @@ import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import epicShowApp from 'ee/epics/epic_show/components/epic_show_app.vue';
import epicHeader from 'ee/epics/epic_show/components/epic_header.vue';
import { stateEvent } from 'ee/epics/constants';
import issuableApp from '~/issue_show/components/app.vue';
import issuableAppEventHub from '~/issue_show/event_hub';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import issueShowData from 'spec/issue_show/mock_data';
import { props } from '../mock_data';
describe('EpicShowApp', () => {
// eslint-disable-next-line
fdescribe('EpicShowApp', () => {
let mock;
let vm;
let headerVm;
......@@ -18,7 +19,6 @@ describe('EpicShowApp', () => {
beforeEach((done) => {
mock = new MockAdapter(axios);
mock.onGet('/realtime_changes').reply(200, issueShowData.initialRequest);
mock.onAny().reply(404, null);
const {
canUpdate,
......@@ -32,6 +32,8 @@ describe('EpicShowApp', () => {
author,
created,
toggleSubscriptionPath,
state,
open,
} = props;
const EpicShowApp = Vue.extend(epicShowApp);
......@@ -41,7 +43,8 @@ describe('EpicShowApp', () => {
headerVm = mountComponent(EpicHeader, {
author,
created,
canDelete: canDestroy,
open,
canUpdate,
});
const IssuableApp = Vue.extend(issuableApp);
......@@ -61,6 +64,7 @@ describe('EpicShowApp', () => {
projectNamespace: '',
showInlineEditButton: true,
toggleSubscriptionPath,
state,
});
setTimeout(done);
......@@ -82,13 +86,33 @@ describe('EpicShowApp', () => {
expect(vm.$el.querySelector('aside.right-sidebar.epic-sidebar')).not.toBe(null);
});
it('should emit delete.issuable when epic is deleted', () => {
const deleteIssuable = jasmine.createSpy();
issuableAppEventHub.$on('delete.issuable', deleteIssuable);
spyOn(window, 'confirm').and.returnValue(true);
spyOnDependency(issuableApp, 'visitUrl');
it('calls `updateStatus` with stateEventType param on service and triggers document events when request is successful', done => {
const queryParam = `epic[state_event]=${stateEvent.close}`;
mock.onPut(`${vm.endpoint}.json?${encodeURI(queryParam)}`).reply(200, {});
spyOn(vm.service, 'updateStatus').and.callThrough();
spyOn(vm, 'triggerDocumentEvent');
vm.toggleEpicStatus(stateEvent.close);
setTimeout(() => {
expect(vm.service.updateStatus).toHaveBeenCalledWith(stateEvent.close);
expect(vm.triggerDocumentEvent).toHaveBeenCalledWith('issuable_vue_app:change', true);
expect(vm.triggerDocumentEvent).toHaveBeenCalledWith('issuable:change', true);
done();
}, 0);
});
it('calls `updateStatus` with stateEventType param on service and shows flash error and triggers document events when request is failed', done => {
const queryParam = `epic[state_event]=${stateEvent.close}`;
mock.onPut(`${vm.endpoint}.json?${encodeURI(queryParam)}`).reply(500, {});
spyOn(vm.service, 'updateStatus').and.callThrough();
spyOn(vm, 'triggerDocumentEvent');
vm.$el.querySelector('.detail-page-header .btn-remove').click();
expect(deleteIssuable).toHaveBeenCalled();
vm.toggleEpicStatus(stateEvent.close);
setTimeout(() => {
expect(vm.service.updateStatus).toHaveBeenCalledWith(stateEvent.close);
expect(vm.triggerDocumentEvent).toHaveBeenCalledWith('issuable_vue_app:change', false);
expect(vm.triggerDocumentEvent).toHaveBeenCalledWith('issuable:change', false);
done();
}, 0);
});
});
......@@ -29,7 +29,7 @@ export const mockParticipants = [
export const contentProps = {
epicId: 1,
endpoint: '',
endpoint: gl.TEST_HOST,
toggleSubscriptionPath: gl.TEST_HOST,
updateEndpoint: gl.TEST_HOST,
todoPath: gl.TEST_HOST,
......@@ -62,6 +62,7 @@ export const contentProps = {
participants: mockParticipants,
subscribed: true,
todoExists: false,
state: 'opened',
};
export const headerProps = {
......@@ -72,6 +73,9 @@ export const headerProps = {
name: 'Administrator',
},
created: (new Date()).toISOString(),
open: true,
canUpdate: true,
canDelete: true,
};
export const mockDatePickerProps = {
......
......@@ -439,6 +439,35 @@ describe Epic do
end
end
describe '#close' do
subject(:epic) { create(:epic, state: 'opened') }
it 'sets closed_at to Time.now when an epic is closed' do
expect { epic.close }.to change { epic.closed_at }.from(nil)
end
it 'changes the state to closed' do
expect { epic.close }.to change { epic.state }.from('opened').to('closed')
end
end
describe '#reopen' do
let(:user) { create(:user) }
subject(:epic) { create(:epic, state: 'closed', closed_at: Time.now, closed_by: user) }
it 'sets closed_at to nil when an epic is reopend' do
expect { epic.reopen }.to change { epic.closed_at }.to(nil)
end
it 'sets closed_by to nil when an epic is reopend' do
expect { epic.reopen }.to change { epic.closed_by }.from(user).to(nil)
end
it 'changes the state to opened' do
expect { epic.reopen }.to change { epic.state }.from('closed').to('opened')
end
end
describe '#to_reference' do
let(:group) { create(:group, path: 'group-a') }
let(:epic) { create(:epic, iid: 1, group: group) }
......
......@@ -269,7 +269,15 @@ describe API::Epics do
describe 'PUT /groups/:id/epics/:epic_iid' do
let(:url) { "/groups/#{group.path}/epics/#{epic.iid}" }
let(:params) { { title: 'new title', description: 'new description', labels: 'label2', start_date_fixed: "2018-07-17", start_date_is_fixed: true } }
let(:params) do
{
title: 'new title',
description: 'new description',
labels: 'label2',
start_date_fixed: "2018-07-17",
start_date_is_fixed: true
}
end
it_behaves_like 'error requests'
......@@ -299,38 +307,61 @@ describe API::Epics do
context 'when the request is correct' do
before do
group.add_developer(user)
put api(url, user), params
end
it 'returns 200 status' do
expect(response).to have_gitlab_http_status(200)
context 'with basic params' do
before do
put api(url, user), params
end
it 'returns 200 status' do
expect(response).to have_gitlab_http_status(200)
end
it 'matches the response schema' do
expect(response).to match_response_schema('public_api/v4/epic', dir: 'ee')
end
it 'updates the epic' do
result = epic.reload
expect(result.title).to eq('new title')
expect(result.description).to eq('new description')
expect(result.labels.first.title).to eq('label2')
expect(result.start_date).to eq(Date.new(2018, 7, 17))
expect(result.start_date_fixed).to eq(Date.new(2018, 7, 17))
expect(result.start_date_is_fixed).to eq(true)
expect(result.due_date_fixed).to eq(nil)
expect(result.due_date_is_fixed).to be_falsey
end
end
it 'matches the response schema' do
expect(response).to match_response_schema('public_api/v4/epic', dir: 'ee')
context 'when state_event is close' do
it 'allows epic to be closed' do
put api(url, user), state_event: 'close'
expect(epic.reload).to be_closed
end
end
it 'updates the epic' do
result = epic.reload
expect(result.title).to eq('new title')
expect(result.description).to eq('new description')
expect(result.labels.first.title).to eq('label2')
expect(result.start_date).to eq(Date.new(2018, 7, 17))
expect(result.start_date_fixed).to eq(Date.new(2018, 7, 17))
expect(result.start_date_is_fixed).to eq(true)
expect(result.due_date_fixed).to eq(nil)
expect(result.due_date_is_fixed).to be_falsey
context 'when state_event is reopen' do
it 'allows epic to be reopend' do
epic.update!(state: 'closed')
put api(url, user), state_event: 'reopen'
expect(epic.reload).to be_opened
end
end
context 'when deprecated start_date and end_date params are present' do
let(:epic) { create(:epic, :use_fixed_dates, group: group) }
let(:new_start_date) { epic.start_date + 1.day }
let(:new_due_date) { epic.end_date + 1.day }
let!(:params) { { start_date: new_start_date, end_date: new_due_date } }
it 'updates start_date_fixed and due_date_fixed' do
put api(url, user), start_date: new_start_date, end_date: new_due_date
result = epic.reload
expect(result.start_date_fixed).to eq(new_start_date)
......@@ -342,9 +373,10 @@ describe API::Epics do
let(:epic) { create(:epic, :use_fixed_dates, group: group) }
let(:new_start_date) { epic.start_date + 1.day }
let(:new_due_date) { epic.end_date + 1.day }
let!(:params) { { start_date_is_fixed: false } }
it 'updates start_date_is_fixed' do
put api(url, user), start_date_is_fixed: false
result = epic.reload
expect(result.start_date_is_fixed).to eq(false)
......
......@@ -109,4 +109,65 @@ describe Notes::QuickActionsService do
end
end
end
describe 'Epics' do
describe '/close' do
let(:note_text) { "/close" }
let(:note) { create(:note, noteable: epic, note: note_text) }
before do
group.add_developer(user)
end
context 'when epics are not enabled' do
it 'does not close the epic' do
expect { execute(note) }.not_to change { epic.state }
end
end
context 'when epics are enabled' do
before do
stub_licensed_features(epics: true)
end
it 'closes the epic' do
expect { execute(note) }.to change { epic.reload.state }.from('opened').to('closed')
end
it 'leaves the note empty' do
expect(execute(note)).to eq('')
end
end
end
describe '/reopen' do
let(:note_text) { "/reopen" }
let(:note) { create(:note, noteable: epic, note: note_text) }
before do
group.add_developer(user)
epic.update!(state: 'closed')
end
context 'when epics are not enabled' do
it 'does not reopen the epic' do
expect { execute(note) }.not_to change { epic.state }
end
end
context 'when epics are enabled' do
before do
stub_licensed_features(epics: true)
end
it 'reopens the epic' do
expect { execute(note) }.to change { epic.reload.state }.from('closed').to('opened')
end
it 'leaves the note empty' do
expect(execute(note)).to eq('')
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Epics::CloseService do
let(:group) { create(:group, :internal) }
let(:user) { create(:user) }
let(:epic) { create(:epic, group: group) }
describe '#execute' do
subject { described_class.new(group, user) }
context 'when epics are disabled' do
before do
group.add_master(user)
end
it 'does not close the epic' do
expect { subject.execute(epic) }.not_to change { epic.state }
end
end
context 'when epics are enabled' do
before do
stub_licensed_features(epics: true)
end
context 'when a user has permissions to update the epic' do
before do
group.add_master(user)
end
context 'when closing an opened epic' do
it 'closes the epic' do
expect { subject.execute(epic) }.to change { epic.state }.from('opened').to('closed')
end
it 'changes closed_by' do
expect { subject.execute(epic) }.to change { epic.closed_by }.to(user)
end
it 'changes closed_at' do
expect { subject.execute(epic) }.to change { epic.closed_at }
end
end
context 'when trying to close a closed epic' do
before do
epic.update(state: :closed)
end
it 'does not change the epic state' do
expect { subject.execute(epic) }.not_to change { epic.state }
end
it 'does not change closed_at' do
expect { subject.execute(epic) }.not_to change { epic.closed_at }
end
it 'does not change closed_by' do
expect { subject.execute(epic) }.not_to change { epic.closed_by }
end
end
end
context 'when a user does not have permissions to update epic' do
it 'does not close the epic' do
expect { subject.execute(epic) }.not_to change { epic.state }
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Epics::ReopenService do
let(:group) { create(:group, :internal) }
let(:user) { create(:user) }
let(:epic) { create(:epic, group: group, state: :closed, closed_at: Date.today, closed_by: user) }
describe '#execute' do
subject { described_class.new(group, user) }
context 'when epics are disabled' do
before do
group.add_master(user)
end
it 'does not reopen the epic' do
expect { subject.execute(epic) }.not_to change { epic.state }
end
end
context 'when epics are enabled' do
before do
stub_licensed_features(epics: true)
end
context 'when a user has permissions to update the epic' do
before do
group.add_master(user)
end
context 'when reopening a closed epic' do
it 'reopens the epic' do
expect { subject.execute(epic) }.to change { epic.state }.from('closed').to('opened')
end
it 'removes closed_by' do
expect { subject.execute(epic) }.to change { epic.closed_by }.to(nil)
end
it 'removes closed_at' do
expect { subject.execute(epic) }.to change { epic.closed_at }.to(nil)
end
end
context 'when trying to reopen an opened epic' do
before do
epic.update(state: :opened)
end
it 'does not change the epic state' do
expect { subject.execute(epic) }.not_to change { epic.state }
end
it 'does not change closed_at' do
expect { subject.execute(epic) }.not_to change { epic.closed_at }
end
it 'does not change closed_by' do
expect { subject.execute(epic) }.not_to change { epic.closed_by }
end
end
end
context 'when a user does not have permissions to update epic' do
it 'does not reopen the epic' do
expect { subject.execute(epic) }.not_to change { epic.state }
end
end
end
end
end
require 'spec_helper'
describe Epics::UpdateService do
let(:group) { create(:group, :internal)}
let(:group) { create(:group, :internal) }
let(:user) { create(:user) }
let(:epic) { create(:epic, group: group) }
describe '#execute' do
before do
stub_licensed_features(epics: true)
group.add_master(user)
end
def update_epic(opts)
described_class.new(group, user, opts).execute(epic)
end
......@@ -21,7 +23,8 @@ describe Epics::UpdateService do
start_date_fixed: '2017-01-09',
start_date_is_fixed: true,
due_date_fixed: '2017-10-21',
due_date_is_fixed: true
due_date_is_fixed: true,
state_event: 'close'
}
end
......@@ -34,6 +37,7 @@ describe Epics::UpdateService do
start_date_fixed: Date.strptime(opts[:start_date_fixed]),
due_date_fixed: Date.strptime(opts[:due_date_fixed])
)
expect(epic).to be_closed
end
it 'updates the last_edited_at value' do
......
......@@ -125,6 +125,9 @@ msgstr ""
msgid "%{group_docs_link_start}Groups%{group_docs_link_end} allow you to manage and collaborate across multiple projects. Members of a group have access to all of its projects."
msgstr ""
msgid "%{issuableType} will be removed! Are you sure?"
msgstr ""
msgid "%{loadingIcon} Started"
msgstr ""
......@@ -1589,6 +1592,9 @@ msgstr ""
msgid "Close"
msgstr ""
msgid "Close epic"
msgstr ""
msgid "Closed"
msgstr ""
......@@ -4161,6 +4167,9 @@ msgstr ""
msgid "Invite"
msgstr ""
msgid "Issue"
msgstr ""
msgid "Issue Boards"
msgstr ""
......@@ -6295,6 +6304,9 @@ msgstr ""
msgid "Rename folder"
msgstr ""
msgid "Reopen epic"
msgstr ""
msgid "Repair authentication"
msgstr ""
......@@ -7963,6 +7975,9 @@ msgstr ""
msgid "Unable to sign you in to the group with SAML due to \"%{reason}\""
msgstr ""
msgid "Unable to update this epic at this time."
msgstr ""
msgid "Undo"
msgstr ""
......
......@@ -21,6 +21,7 @@ describe('Edit Actions components', () => {
propsData: {
canDestroy: true,
formState: store.formState,
issuableType: 'issue',
},
}).$mount();
......
......@@ -15,6 +15,7 @@ describe('Inline edit form component', () => {
description: 'a',
lockedWarningVisible: false,
},
issuableType: 'issue',
markdownPreviewPath: '/',
markdownDocsPath: '/',
projectPath: '/',
......
......@@ -122,13 +122,27 @@ describe DeleteInconsistentInternalIdRecords, :migration do
let!(:group1) { create(:group) }
let!(:group2) { create(:group) }
let!(:group3) { create(:group) }
let!(:user) { create(:user) }
let(:internal_id_query) { ->(group) { InternalId.where(usage: InternalId.usages['epics'], namespace: group) } }
before do
create_list(:epic, 3, group: group1)
create_list(:epic, 3, group: group2)
create_list(:epic, 3, group: group3)
# we use state enum in Epic but state field was added after this migration
epics = table(:epics)
epics.belongs_to(:group)
epics.include(AtomicInternalId)
epics.has_internal_id(:iid, scope: :group, init: ->(s) { s&.group&.epics&.maximum(:iid) })
epics.create!(title: 'Epic 1', title_html: 'Epic 1', group_id: group1.id, author_id: user.id)
epics.create!(title: 'Epic 2', title_html: 'Epic 2', group_id: group1.id, author_id: user.id)
epics.create!(title: 'Epic 3', title_html: 'Epic 3', group_id: group1.id, author_id: user.id)
epics.create!(title: 'Epic 4', title_html: 'Epic 4', group_id: group2.id, author_id: user.id)
epics.create!(title: 'Epic 5', title_html: 'Epic 5', group_id: group2.id, author_id: user.id)
epics.create!(title: 'Epic 6', title_html: 'Epic 6', group_id: group2.id, author_id: user.id)
epics.create!(title: 'Epic 7', title_html: 'Epic 7', group_id: group3.id, author_id: user.id)
epics.create!(title: 'Epic 8', title_html: 'Epic 8', group_id: group3.id, author_id: user.id)
epics.create!(title: 'Epic 9', title_html: 'Epic 9', group_id: group3.id, author_id: user.id)
internal_id_query.call(group1).first.tap do |iid|
iid.last_value = iid.last_value - 2
......
......@@ -84,15 +84,32 @@ describe Issue do
end
end
describe '#closed_at' do
it 'sets closed_at to Time.now when issue is closed' do
issue = create(:issue, state: 'opened')
describe '#close' do
subject(:issue) { create(:issue, state: 'opened') }
expect(issue.closed_at).to be_nil
it 'sets closed_at to Time.now when an issue is closed' do
expect { issue.close }.to change { issue.closed_at }.from(nil)
end
issue.close
it 'changes the state to closed' do
expect { issue.close }.to change { issue.state }.from('opened').to('closed')
end
end
describe '#reopen' do
let(:user) { create(:user) }
let(:issue) { create(:issue, state: 'closed', closed_at: Time.now, closed_by: user) }
it 'sets closed_at to nil when an issue is reopend' do
expect { issue.reopen }.to change { issue.closed_at }.to(nil)
end
it 'sets closed_by to nil when an issue is reopend' do
expect { issue.reopen }.to change { issue.closed_by }.from(user).to(nil)
end
expect(issue.closed_at).to be_present
it 'changes the state to opened' do
expect { issue.reopen }.to change { issue.state }.from('closed').to('opened')
end
end
......
......@@ -9,6 +9,10 @@ module MigrationsHelpers
Class.new(active_record_base) do
self.table_name = name
self.inheritance_column = :_type_disabled
def self.name
table_name.singularize.camelcase
end
end
end
......
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