Commit 9f214a9e authored by Douglas Barbosa Alexandre's avatar Douglas Barbosa Alexandre

Merge branch '7013-add-epics-close-support' into 'master'

Add support for closing epics

Closes #7340, #3678, and #7013

See merge request gitlab-org/gitlab-ee!7302
parents f7007ca6 b0252651
...@@ -293,6 +293,7 @@ ...@@ -293,6 +293,7 @@
:show-delete-button="showDeleteButton" :show-delete-button="showDeleteButton"
:can-attach-file="canAttachFile" :can-attach-file="canAttachFile"
:enable-autocomplete="enableAutocomplete" :enable-autocomplete="enableAutocomplete"
:issuable-type="issuableType"
/> />
<recaptcha-modal <recaptcha-modal
......
<script> <script>
import { __, sprintf } from '~/locale';
import updateMixin from '../mixins/update'; import updateMixin from '../mixins/update';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
const issuableTypes = {
issue: __('Issue'),
epic: __('Epic'),
};
export default { export default {
mixins: [updateMixin], mixins: [updateMixin],
props: { props: {
...@@ -18,6 +24,10 @@ ...@@ -18,6 +24,10 @@
required: false, required: false,
default: true, default: true,
}, },
issuableType: {
type: String,
required: true,
},
}, },
data() { data() {
return { return {
...@@ -37,8 +47,11 @@ ...@@ -37,8 +47,11 @@
eventHub.$emit('close.form'); eventHub.$emit('close.form');
}, },
deleteIssuable() { deleteIssuable() {
const confirmMessage = sprintf(__('%{issuableType} will be removed! Are you sure?'), {
issuableType: issuableTypes[this.issuableType],
});
// eslint-disable-next-line no-alert // eslint-disable-next-line no-alert
if (window.confirm('Issue will be removed! Are you sure?')) { if (window.confirm(confirmMessage)) {
this.deleteLoading = true; this.deleteLoading = true;
eventHub.$emit('delete.issuable'); eventHub.$emit('delete.issuable');
......
...@@ -27,6 +27,10 @@ ...@@ -27,6 +27,10 @@
required: false, required: false,
default: () => [], default: () => [],
}, },
issuableType: {
type: String,
required: true,
},
markdownPreviewPath: { markdownPreviewPath: {
type: String, type: String,
required: true, required: true,
...@@ -110,6 +114,7 @@ ...@@ -110,6 +114,7 @@
:form-state="formState" :form-state="formState"
:can-destroy="canDestroy" :can-destroy="canDestroy"
:show-delete-button="showDeleteButton" :show-delete-button="showDeleteButton"
:issuable-type="issuableType"
/> />
</form> </form>
</template> </template>
...@@ -5,7 +5,8 @@ module Notes ...@@ -5,7 +5,8 @@ module Notes
UPDATE_SERVICES = { UPDATE_SERVICES = {
'Issue' => Issues::UpdateService, 'Issue' => Issues::UpdateService,
'MergeRequest' => MergeRequests::UpdateService, 'MergeRequest' => MergeRequests::UpdateService,
'Commit' => Commits::TagService 'Commit' => Commits::TagService,
'Epic' => Epics::UpdateService
}.freeze }.freeze
def self.noteable_update_service(note) def self.noteable_update_service(note)
......
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
...@@ -973,10 +973,14 @@ ActiveRecord::Schema.define(version: 20180917214204) do ...@@ -973,10 +973,14 @@ ActiveRecord::Schema.define(version: 20180917214204) do
t.date "due_date_fixed" t.date "due_date_fixed"
t.boolean "start_date_is_fixed" t.boolean "start_date_is_fixed"
t.boolean "due_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 end
add_index "epics", ["assignee_id"], name: "index_epics_on_assignee_id", using: :btree 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", ["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", ["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", ["group_id"], name: "index_epics_on_group_id", using: :btree
add_index "epics", ["iid"], name: "index_epics_on_iid", using: :btree add_index "epics", ["iid"], name: "index_epics_on_iid", using: :btree
...@@ -3123,6 +3127,7 @@ ActiveRecord::Schema.define(version: 20180917214204) do ...@@ -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", "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: "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: "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", "projects", on_delete: :cascade
add_foreign_key "events", "users", column: "author_id", name: "fk_edfd187b6f", 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 add_foreign_key "fork_network_members", "fork_networks", on_delete: :cascade
......
...@@ -208,6 +208,7 @@ PUT /groups/:id/epics/:epic_iid ...@@ -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) | | `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_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) | | `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 ```bash
curl --header PUT "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/1/epics/5?title=New%20Title 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. ...@@ -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 Deleting an epic releases all existing issues from their associated epic in the
system. 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 ## Navigating to an epic from an issue
If an issue belongs to an epic, you can navigate to the containing epic with the If an issue belongs to an epic, you can navigate to the containing epic with the
......
...@@ -12,8 +12,8 @@ do. ...@@ -12,8 +12,8 @@ do.
| Command | Action | | Command | Action |
|:---------------------------|:-------------| |:---------------------------|:-------------|
| `/close` | Close the issue or merge request | | `/close` | Close the issue, merge request or epic |
| `/reopen` | Reopen the issue or merge request | | `/reopen` | Reopen the issue, merge request or epic |
| `/merge` | Merge (when pipeline succeeds) | | `/merge` | Merge (when pipeline succeeds) |
| `/title <New title>` | Change title | | `/title <New title>` | Change title |
| `/assign @user1 @user2 ` | Add assignee(s) | | `/assign @user1 @user2 ` | Add assignee(s) |
......
export const status = {
open: 'opened',
close: 'closed',
};
export const stateEvent = {
close: 'close',
reopen: 'reopen',
};
<script> <script>
import $ from 'jquery';
import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import timeagoTooltip from '~/vue_shared/components/time_ago_tooltip.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 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 eventHub from '../../event_hub';
import { stateEvent } from '../../constants';
export default { export default {
name: 'EpicHeader', name: 'EpicHeader',
...@@ -12,9 +15,10 @@ ...@@ -12,9 +15,10 @@
tooltip, tooltip,
}, },
components: { components: {
Icon,
LoadingButton,
userAvatarLink, userAvatarLink,
timeagoTooltip, timeagoTooltip,
loadingButton,
}, },
props: { props: {
author: { author: {
...@@ -26,17 +30,42 @@ ...@@ -26,17 +30,42 @@
type: String, type: String,
required: true, required: true,
}, },
canDelete: { open: {
type: Boolean,
required: true,
},
canUpdate: {
required: true,
type: Boolean, type: Boolean,
required: false,
default: false,
}, },
}, },
data() { data() {
return { return {
deleteLoading: false, 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: { methods: {
deleteEpic() { deleteEpic() {
if (window.confirm(s__('Epic will be removed! Are you sure?'))) { // eslint-disable-line no-alert if (window.confirm(s__('Epic will be removed! Are you sure?'))) { // eslint-disable-line no-alert
...@@ -47,34 +76,54 @@ ...@@ -47,34 +76,54 @@
toggleSidebar() { toggleSidebar() {
eventHub.$emit('toggleSidebar'); eventHub.$emit('toggleSidebar');
}, },
toggleStatus() {
this.statusUpdating = true;
this.$emit('toggleEpicStatus', this.isEpicOpen ? stateEvent.close : stateEvent.reopen);
},
}, },
}; };
</script> </script>
<template> <template>
<div class="detail-page-header"> <div class="detail-page-header">
<div class="issuable-meta"> <div class="detail-page-header-body">
{{ s__('Opened') }} <div
<timeago-tooltip :time="created" /> :class="{ 'status-box-open': isEpicOpen, 'status-box-issue-closed': !isEpicOpen }"
{{ s__('by') }} class="issuable-status-box status-box"
<strong> >
<user-avatar-link <icon
:link-href="author.url" :name="statusIcon"
:img-src="author.src" css-classes="d-block d-sm-none"
:img-size="24"
:tooltip-text="author.username"
:username="author.name"
img-css-classes="avatar-inline"
/> />
</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> </div>
<loading-button
v-if="canDelete"
:loading="deleteLoading"
:label="s__('Delete')"
container-class="btn btn-remove btn-inverted flex-right"
@click="deleteEpic"
/>
<button <button
:aria-label="__('toggle collapse')" :aria-label="__('toggle collapse')"
class="btn btn-default float-right d-block d-sm-none class="btn btn-default float-right d-block d-sm-none
......
<script> <script>
/* eslint-disable vue/require-default-prop */ /* eslint-disable vue/require-default-prop */
import $ from 'jquery';
import issuableApp from '~/issue_show/components/app.vue'; 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 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 epicSidebar from '../../sidebar/components/sidebar_app.vue';
import SidebarContext from '../sidebar_context'; import SidebarContext from '../sidebar_context';
import epicHeader from './epic_header.vue'; import epicHeader from './epic_header.vue';
import EpicsService from '../../service/epics_service';
import { status, stateEvent } from '../../constants';
export default { export default {
name: 'EpicShowApp', name: 'EpicShowApp',
...@@ -174,6 +178,11 @@ ...@@ -174,6 +178,11 @@
type: String, type: String,
required: true, required: true,
}, },
state: {
type: String,
required: true,
default: status.open,
},
}, },
data() { data() {
return { return {
...@@ -181,14 +190,42 @@ ...@@ -181,14 +190,42 @@
issuableRef: '', issuableRef: '',
projectPath: this.groupPath, projectPath: this.groupPath,
projectNamespace: '', projectNamespace: '',
service: new EpicsService({
endpoint: this.endpoint,
}),
}; };
}, },
computed: {
open() {
return this.state === status.open;
},
},
mounted() { mounted() {
this.sidebarContext = new SidebarContext(); this.sidebarContext = new SidebarContext();
}, },
methods: { methods: {
deleteEpic() { triggerDocumentEvent(eventName, isClosed) {
issuableAppEventHub.$emit('delete.issuable'); $(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 @@ ...@@ -199,8 +236,10 @@
<epic-header <epic-header
:author="author" :author="author"
:created="created" :created="created"
:open="open"
:can-delete="canDestroy" :can-delete="canDestroy"
@deleteEpic="deleteEpic" :can-update="canUpdate"
@toggleEpicStatus="toggleEpicStatus"
/> />
<div class="issuable-details content-block"> <div class="issuable-details content-block">
<div class="detail-page-description"> <div class="detail-page-description">
...@@ -219,7 +258,7 @@ ...@@ -219,7 +258,7 @@
:project-path="projectPath" :project-path="projectPath"
:project-namespace="projectNamespace" :project-namespace="projectNamespace"
:show-inline-edit-button="true" :show-inline-edit-button="true"
:show-delete-button="false" :show-delete-button="canDestroy"
:enable-autocomplete="true" :enable-autocomplete="true"
issuable-type="epic" 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 @@ ...@@ -35,8 +35,15 @@
} }
@include media-breakpoint-down(xs) { @include media-breakpoint-down(xs) {
.epic-page-container .detail-page-header { .epic-page-container {
display: flex; .detail-page-header {
display: flex;
}
.detail-page-header-actions {
width: auto;
margin-top: 0;
}
} }
} }
......
...@@ -73,6 +73,7 @@ class Groups::EpicsController < Groups::ApplicationController ...@@ -73,6 +73,7 @@ class Groups::EpicsController < Groups::ApplicationController
:start_date_is_fixed, :start_date_is_fixed,
:due_date_fixed, :due_date_fixed,
:due_date_is_fixed, :due_date_is_fixed,
:state_event,
label_ids: [] label_ids: []
] ]
end end
......
...@@ -25,7 +25,8 @@ module EpicsHelper ...@@ -25,7 +25,8 @@ module EpicsHelper
due_date_fixed: epic.due_date_fixed, due_date_fixed: epic.due_date_fixed,
due_date_from_milestones: epic.due_date_from_milestones, due_date_from_milestones: epic.due_date_from_milestones,
due_date_sourcing_milestone_title: epic.due_date_sourcing_milestone&.title, 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? epic_meta[:todo_delete_path] = dashboard_todo_path(todo) if todo.present?
......
...@@ -11,6 +11,22 @@ module EE ...@@ -11,6 +11,22 @@ module EE
include Awardable include Awardable
include LabelEventable 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 :assignee, class_name: "User"
belongs_to :group belongs_to :group
belongs_to :start_date_sourcing_milestone, class_name: 'Milestone' belongs_to :start_date_sourcing_milestone, class_name: 'Milestone'
......
...@@ -14,6 +14,7 @@ class EpicEntity < IssuableEntity ...@@ -14,6 +14,7 @@ class EpicEntity < IssuableEntity
expose :end_date, as: :due_date expose :end_date, as: :due_date
expose :due_date_is_fixed?, as: :due_date_is_fixed expose :due_date_is_fixed?, as: :due_date_is_fixed
expose :due_date_fixed, :due_date_from_milestones expose :due_date_fixed, :due_date_from_milestones
expose :state
expose :web_url do |epic| expose :web_url do |epic|
group_epic_path(epic.group, epic) group_epic_path(epic.group, epic)
...@@ -24,6 +25,10 @@ class EpicEntity < IssuableEntity ...@@ -24,6 +25,10 @@ class EpicEntity < IssuableEntity
expose :can_create_note do |epic| expose :can_create_note do |epic|
can?(request.current_user, :create_note, epic) can?(request.current_user, :create_note, epic)
end end
expose :can_update do |epic|
can?(request.current_user, :update_epic, epic)
end
end end
expose :create_note_path do |epic| expose :create_note_path do |epic|
......
...@@ -2,7 +2,7 @@ module Epics ...@@ -2,7 +2,7 @@ module Epics
class BaseService < IssuableBaseService class BaseService < IssuableBaseService
attr_reader :group attr_reader :group
def initialize(group, current_user, params) def initialize(group, current_user, params = {})
@group, @current_user, @params = group, current_user, params @group, @current_user, @params = group, current_user, params
end end
...@@ -20,5 +20,13 @@ module Epics ...@@ -20,5 +20,13 @@ module Epics
def parent def parent
group group
end end
def close_service
Epics::CloseService
end
def reopen_service
Epics::ReopenService
end
end 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 ...@@ -112,7 +112,8 @@ module API
optional :end_date, as: :due_date_fixed, type: String, desc: 'The due date of an epic' 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 :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' 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 end
put ':id/(-/)epics/:epic_iid' do put ':id/(-/)epics/:epic_iid' do
authorize_can_admin! authorize_can_admin!
......
...@@ -180,6 +180,7 @@ module EE ...@@ -180,6 +180,7 @@ module EE
expose :end_date, as: :due_date expose :end_date, as: :due_date
expose :due_date_is_fixed?, as: :due_date_is_fixed, if: can_admin_epic 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 :due_date_fixed, :due_date_from_milestones, if: can_admin_epic
expose :state
expose :created_at expose :created_at
expose :updated_at expose :updated_at
expose :labels do |epic, options| expose :labels do |epic, options|
......
...@@ -225,25 +225,71 @@ describe Groups::EpicsController do ...@@ -225,25 +225,71 @@ describe Groups::EpicsController do
end end
describe 'PUT #update' do 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 before do
group.add_developer(user) 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 end
it 'returns status 200' do context 'with correct basic params' do
expect(response.status).to eq(200) 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 end
it 'updates the epic correctly' do context 'when state_event param is close' do
epic.reload 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') def update_epic(epic, params)
expect(epic.labels).to eq([label]) put :update, group_id: epic.group.to_param, id: epic.to_param, epic: params, format: :json
expect(epic.start_date_fixed).to eq(date)
expect(epic.start_date).to eq(date)
expect(epic.start_date_is_fixed).to eq(true)
end end
end end
......
...@@ -25,11 +25,12 @@ describe 'Delete Epic', :js do ...@@ -25,11 +25,12 @@ describe 'Delete Epic', :js do
group.add_owner(user) group.add_owner(user)
visit group_epic_path(group, epic) visit group_epic_path(group, epic)
wait_for_requests wait_for_requests
find('.js-issuable-edit').click
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 'Epic will be removed! Are you sure?' do page.accept_alert 'Epic will be removed! Are you sure?' do
find('.detail-page-header button').click find(:button, text: 'Delete').click
end end
wait_for_requests wait_for_requests
......
...@@ -139,10 +139,10 @@ describe 'Update Epic', :js do ...@@ -139,10 +139,10 @@ describe 'Update Epic', :js do
wait_for_requests wait_for_requests
end 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 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 end
end end
...@@ -33,6 +33,7 @@ ...@@ -33,6 +33,7 @@
"due_date_fixed": { "type": ["date", "null"] }, "due_date_fixed": { "type": ["date", "null"] },
"due_date_from_milestones": { "type": ["date", "null"] }, "due_date_from_milestones": { "type": ["date", "null"] },
"due_date_is_fixed": { "type": "boolean" }, "due_date_is_fixed": { "type": "boolean" },
"state": { "type": "string" },
"created_at": { "type": ["string", "null"] }, "created_at": { "type": ["string", "null"] },
"updated_at": { "type": ["string", "null"] } "updated_at": { "type": ["string", "null"] }
}, },
......
...@@ -27,6 +27,7 @@ ...@@ -27,6 +27,7 @@
"due_date_fixed": { "type": ["date", "null"] }, "due_date_fixed": { "type": ["date", "null"] },
"due_date_from_milestones": { "type": ["date", "null"] }, "due_date_from_milestones": { "type": ["date", "null"] },
"due_date_is_fixed": { "type": "boolean" }, "due_date_is_fixed": { "type": "boolean" },
"state": { "type": "string" },
"created_at": { "type": ["string", "null"] }, "created_at": { "type": ["string", "null"] },
"updated_at": { "type": ["string", "null"] }, "updated_at": { "type": ["string", "null"] },
"labels": { "labels": {
......
...@@ -29,7 +29,7 @@ describe EpicsHelper do ...@@ -29,7 +29,7 @@ describe EpicsHelper do
expected_keys = %i(initial meta namespace labels_path toggle_subscription_path labels_web_url epics_web_url) 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(data.keys).to match_array(expected_keys)
expect(meta_data.keys).to match_array(%w[ 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 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 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 ...@@ -44,6 +44,40 @@ describe EpicsHelper do
expect(meta_data['due_date']).to eq('2000-01-02') expect(meta_data['due_date']).to eq('2000-01-02')
expect(meta_data['due_date_sourcing_milestone_title']).to eq(milestone.title) expect(meta_data['due_date_sourcing_milestone_title']).to eq(milestone.title)
end 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 end
describe '#epic_endpoint_query_params' do describe '#epic_endpoint_query_params' do
......
import Vue from 'vue'; import Vue from 'vue';
import epicHeader from 'ee/epics/epic_show/components/epic_header.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 mountComponent from 'spec/helpers/vue_mount_component_helper';
import { headerProps } from '../mock_data'; import { headerProps } from '../mock_data';
...@@ -38,41 +39,90 @@ describe('epicHeader', () => { ...@@ -38,41 +39,90 @@ describe('epicHeader', () => {
expect(vm.$el.querySelector('button.js-sidebar-toggle')).not.toBe(null); expect(vm.$el.querySelector('button.js-sidebar-toggle')).not.toBe(null);
}); });
describe('canDelete', () => { it('should render status badge', () => {
it('should not show loading button by default', () => { const badgeEl = vm.$el.querySelector('.issuable-status-box');
expect(vm.$el.querySelector('.btn-remove')).toBeNull(); 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 => { it('returns `mobile-issue-close` when `isEpicOpen` prop is false', () => {
vm.canDelete = true; vm.isEpicOpen = false;
Vue.nextTick(() => { expect(vm.statusIcon).toBe('mobile-issue-close');
expect(vm.$el.querySelector('.btn-remove')).toBeDefined();
done();
}); });
}); });
});
describe('delete epic', () => { describe('statusText', () => {
let deleteEpic; it('returns `Open` when `isEpicOpen` prop is true', () => {
vm.isEpicOpen = true;
expect(vm.statusText).toBe('Open');
});
beforeEach(done => { it('returns `Closed` when `isEpicOpen` prop is false', () => {
deleteEpic = jasmine.createSpy(); vm.isEpicOpen = false;
spyOn(window, 'confirm').and.returnValue(true); expect(vm.statusText).toBe('Closed');
vm.canDelete = true; });
vm.$on('deleteEpic', deleteEpic); });
Vue.nextTick(() => { describe('actionButtonClass', () => {
vm.$el.querySelector('.btn-remove').click(); it('returns classes `btn btn-grouped js-btn-epic-action` & `btn-close` when `isEpicOpen` prop is true', () => {
done(); 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', () => { describe('actionButtonText', () => {
expect(vm.deleteLoading).toEqual(true); 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', () => { describe('methods', () => {
expect(deleteEpic).toHaveBeenCalled(); 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'; ...@@ -3,13 +3,14 @@ import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import epicShowApp from 'ee/epics/epic_show/components/epic_show_app.vue'; import epicShowApp from 'ee/epics/epic_show/components/epic_show_app.vue';
import epicHeader from 'ee/epics/epic_show/components/epic_header.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 issuableApp from '~/issue_show/components/app.vue';
import issuableAppEventHub from '~/issue_show/event_hub';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import issueShowData from 'spec/issue_show/mock_data'; import issueShowData from 'spec/issue_show/mock_data';
import { props } from '../mock_data'; import { props } from '../mock_data';
describe('EpicShowApp', () => { // eslint-disable-next-line
fdescribe('EpicShowApp', () => {
let mock; let mock;
let vm; let vm;
let headerVm; let headerVm;
...@@ -18,7 +19,6 @@ describe('EpicShowApp', () => { ...@@ -18,7 +19,6 @@ describe('EpicShowApp', () => {
beforeEach((done) => { beforeEach((done) => {
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
mock.onGet('/realtime_changes').reply(200, issueShowData.initialRequest); mock.onGet('/realtime_changes').reply(200, issueShowData.initialRequest);
mock.onAny().reply(404, null);
const { const {
canUpdate, canUpdate,
...@@ -32,6 +32,8 @@ describe('EpicShowApp', () => { ...@@ -32,6 +32,8 @@ describe('EpicShowApp', () => {
author, author,
created, created,
toggleSubscriptionPath, toggleSubscriptionPath,
state,
open,
} = props; } = props;
const EpicShowApp = Vue.extend(epicShowApp); const EpicShowApp = Vue.extend(epicShowApp);
...@@ -41,7 +43,8 @@ describe('EpicShowApp', () => { ...@@ -41,7 +43,8 @@ describe('EpicShowApp', () => {
headerVm = mountComponent(EpicHeader, { headerVm = mountComponent(EpicHeader, {
author, author,
created, created,
canDelete: canDestroy, open,
canUpdate,
}); });
const IssuableApp = Vue.extend(issuableApp); const IssuableApp = Vue.extend(issuableApp);
...@@ -61,6 +64,7 @@ describe('EpicShowApp', () => { ...@@ -61,6 +64,7 @@ describe('EpicShowApp', () => {
projectNamespace: '', projectNamespace: '',
showInlineEditButton: true, showInlineEditButton: true,
toggleSubscriptionPath, toggleSubscriptionPath,
state,
}); });
setTimeout(done); setTimeout(done);
...@@ -82,13 +86,33 @@ describe('EpicShowApp', () => { ...@@ -82,13 +86,33 @@ describe('EpicShowApp', () => {
expect(vm.$el.querySelector('aside.right-sidebar.epic-sidebar')).not.toBe(null); expect(vm.$el.querySelector('aside.right-sidebar.epic-sidebar')).not.toBe(null);
}); });
it('should emit delete.issuable when epic is deleted', () => { it('calls `updateStatus` with stateEventType param on service and triggers document events when request is successful', done => {
const deleteIssuable = jasmine.createSpy(); const queryParam = `epic[state_event]=${stateEvent.close}`;
issuableAppEventHub.$on('delete.issuable', deleteIssuable); mock.onPut(`${vm.endpoint}.json?${encodeURI(queryParam)}`).reply(200, {});
spyOn(window, 'confirm').and.returnValue(true); spyOn(vm.service, 'updateStatus').and.callThrough();
spyOnDependency(issuableApp, 'visitUrl'); 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(); vm.toggleEpicStatus(stateEvent.close);
expect(deleteIssuable).toHaveBeenCalled(); 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 = [ ...@@ -29,7 +29,7 @@ export const mockParticipants = [
export const contentProps = { export const contentProps = {
epicId: 1, epicId: 1,
endpoint: '', endpoint: gl.TEST_HOST,
toggleSubscriptionPath: gl.TEST_HOST, toggleSubscriptionPath: gl.TEST_HOST,
updateEndpoint: gl.TEST_HOST, updateEndpoint: gl.TEST_HOST,
todoPath: gl.TEST_HOST, todoPath: gl.TEST_HOST,
...@@ -62,6 +62,7 @@ export const contentProps = { ...@@ -62,6 +62,7 @@ export const contentProps = {
participants: mockParticipants, participants: mockParticipants,
subscribed: true, subscribed: true,
todoExists: false, todoExists: false,
state: 'opened',
}; };
export const headerProps = { export const headerProps = {
...@@ -72,6 +73,9 @@ export const headerProps = { ...@@ -72,6 +73,9 @@ export const headerProps = {
name: 'Administrator', name: 'Administrator',
}, },
created: (new Date()).toISOString(), created: (new Date()).toISOString(),
open: true,
canUpdate: true,
canDelete: true,
}; };
export const mockDatePickerProps = { export const mockDatePickerProps = {
......
...@@ -439,6 +439,35 @@ describe Epic do ...@@ -439,6 +439,35 @@ describe Epic do
end end
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 describe '#to_reference' do
let(:group) { create(:group, path: 'group-a') } let(:group) { create(:group, path: 'group-a') }
let(:epic) { create(:epic, iid: 1, group: group) } let(:epic) { create(:epic, iid: 1, group: group) }
......
...@@ -269,7 +269,15 @@ describe API::Epics do ...@@ -269,7 +269,15 @@ describe API::Epics do
describe 'PUT /groups/:id/epics/:epic_iid' do describe 'PUT /groups/:id/epics/:epic_iid' do
let(:url) { "/groups/#{group.path}/epics/#{epic.iid}" } 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' it_behaves_like 'error requests'
...@@ -299,38 +307,61 @@ describe API::Epics do ...@@ -299,38 +307,61 @@ describe API::Epics do
context 'when the request is correct' do context 'when the request is correct' do
before do before do
group.add_developer(user) group.add_developer(user)
put api(url, user), params
end end
it 'returns 200 status' do context 'with basic params' do
expect(response).to have_gitlab_http_status(200) 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 end
it 'matches the response schema' do context 'when state_event is close' do
expect(response).to match_response_schema('public_api/v4/epic', dir: 'ee') it 'allows epic to be closed' do
put api(url, user), state_event: 'close'
expect(epic.reload).to be_closed
end
end end
it 'updates the epic' do context 'when state_event is reopen' do
result = epic.reload it 'allows epic to be reopend' do
epic.update!(state: 'closed')
expect(result.title).to eq('new title')
expect(result.description).to eq('new description') put api(url, user), state_event: 'reopen'
expect(result.labels.first.title).to eq('label2')
expect(result.start_date).to eq(Date.new(2018, 7, 17)) expect(epic.reload).to be_opened
expect(result.start_date_fixed).to eq(Date.new(2018, 7, 17)) end
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
context 'when deprecated start_date and end_date params are present' do context 'when deprecated start_date and end_date params are present' do
let(:epic) { create(:epic, :use_fixed_dates, group: group) } let(:epic) { create(:epic, :use_fixed_dates, group: group) }
let(:new_start_date) { epic.start_date + 1.day } let(:new_start_date) { epic.start_date + 1.day }
let(:new_due_date) { epic.end_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 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 result = epic.reload
expect(result.start_date_fixed).to eq(new_start_date) expect(result.start_date_fixed).to eq(new_start_date)
...@@ -342,9 +373,10 @@ describe API::Epics do ...@@ -342,9 +373,10 @@ describe API::Epics do
let(:epic) { create(:epic, :use_fixed_dates, group: group) } let(:epic) { create(:epic, :use_fixed_dates, group: group) }
let(:new_start_date) { epic.start_date + 1.day } let(:new_start_date) { epic.start_date + 1.day }
let(:new_due_date) { epic.end_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 it 'updates start_date_is_fixed' do
put api(url, user), start_date_is_fixed: false
result = epic.reload result = epic.reload
expect(result.start_date_is_fixed).to eq(false) expect(result.start_date_is_fixed).to eq(false)
......
...@@ -109,4 +109,65 @@ describe Notes::QuickActionsService do ...@@ -109,4 +109,65 @@ describe Notes::QuickActionsService do
end end
end 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 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' require 'spec_helper'
describe Epics::UpdateService do describe Epics::UpdateService do
let(:group) { create(:group, :internal)} let(:group) { create(:group, :internal) }
let(:user) { create(:user) } let(:user) { create(:user) }
let(:epic) { create(:epic, group: group) } let(:epic) { create(:epic, group: group) }
describe '#execute' do describe '#execute' do
before do before do
stub_licensed_features(epics: true) stub_licensed_features(epics: true)
group.add_master(user)
end end
def update_epic(opts) def update_epic(opts)
described_class.new(group, user, opts).execute(epic) described_class.new(group, user, opts).execute(epic)
end end
...@@ -21,7 +23,8 @@ describe Epics::UpdateService do ...@@ -21,7 +23,8 @@ describe Epics::UpdateService do
start_date_fixed: '2017-01-09', start_date_fixed: '2017-01-09',
start_date_is_fixed: true, start_date_is_fixed: true,
due_date_fixed: '2017-10-21', due_date_fixed: '2017-10-21',
due_date_is_fixed: true due_date_is_fixed: true,
state_event: 'close'
} }
end end
...@@ -34,6 +37,7 @@ describe Epics::UpdateService do ...@@ -34,6 +37,7 @@ describe Epics::UpdateService do
start_date_fixed: Date.strptime(opts[:start_date_fixed]), start_date_fixed: Date.strptime(opts[:start_date_fixed]),
due_date_fixed: Date.strptime(opts[:due_date_fixed]) due_date_fixed: Date.strptime(opts[:due_date_fixed])
) )
expect(epic).to be_closed
end end
it 'updates the last_edited_at value' do it 'updates the last_edited_at value' do
......
...@@ -125,6 +125,9 @@ msgstr "" ...@@ -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." 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 "" msgstr ""
msgid "%{issuableType} will be removed! Are you sure?"
msgstr ""
msgid "%{loadingIcon} Started" msgid "%{loadingIcon} Started"
msgstr "" msgstr ""
...@@ -1589,6 +1592,9 @@ msgstr "" ...@@ -1589,6 +1592,9 @@ msgstr ""
msgid "Close" msgid "Close"
msgstr "" msgstr ""
msgid "Close epic"
msgstr ""
msgid "Closed" msgid "Closed"
msgstr "" msgstr ""
...@@ -4161,6 +4167,9 @@ msgstr "" ...@@ -4161,6 +4167,9 @@ msgstr ""
msgid "Invite" msgid "Invite"
msgstr "" msgstr ""
msgid "Issue"
msgstr ""
msgid "Issue Boards" msgid "Issue Boards"
msgstr "" msgstr ""
...@@ -6295,6 +6304,9 @@ msgstr "" ...@@ -6295,6 +6304,9 @@ msgstr ""
msgid "Rename folder" msgid "Rename folder"
msgstr "" msgstr ""
msgid "Reopen epic"
msgstr ""
msgid "Repair authentication" msgid "Repair authentication"
msgstr "" msgstr ""
...@@ -7963,6 +7975,9 @@ msgstr "" ...@@ -7963,6 +7975,9 @@ msgstr ""
msgid "Unable to sign you in to the group with SAML due to \"%{reason}\"" msgid "Unable to sign you in to the group with SAML due to \"%{reason}\""
msgstr "" msgstr ""
msgid "Unable to update this epic at this time."
msgstr ""
msgid "Undo" msgid "Undo"
msgstr "" msgstr ""
......
...@@ -21,6 +21,7 @@ describe('Edit Actions components', () => { ...@@ -21,6 +21,7 @@ describe('Edit Actions components', () => {
propsData: { propsData: {
canDestroy: true, canDestroy: true,
formState: store.formState, formState: store.formState,
issuableType: 'issue',
}, },
}).$mount(); }).$mount();
......
...@@ -15,6 +15,7 @@ describe('Inline edit form component', () => { ...@@ -15,6 +15,7 @@ describe('Inline edit form component', () => {
description: 'a', description: 'a',
lockedWarningVisible: false, lockedWarningVisible: false,
}, },
issuableType: 'issue',
markdownPreviewPath: '/', markdownPreviewPath: '/',
markdownDocsPath: '/', markdownDocsPath: '/',
projectPath: '/', projectPath: '/',
......
...@@ -122,13 +122,27 @@ describe DeleteInconsistentInternalIdRecords, :migration do ...@@ -122,13 +122,27 @@ describe DeleteInconsistentInternalIdRecords, :migration do
let!(:group1) { create(:group) } let!(:group1) { create(:group) }
let!(:group2) { create(:group) } let!(:group2) { create(:group) }
let!(:group3) { create(:group) } let!(:group3) { create(:group) }
let!(:user) { create(:user) }
let(:internal_id_query) { ->(group) { InternalId.where(usage: InternalId.usages['epics'], namespace: group) } } let(:internal_id_query) { ->(group) { InternalId.where(usage: InternalId.usages['epics'], namespace: group) } }
before do before do
create_list(:epic, 3, group: group1) # we use state enum in Epic but state field was added after this migration
create_list(:epic, 3, group: group2) epics = table(:epics)
create_list(:epic, 3, group: group3)
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| internal_id_query.call(group1).first.tap do |iid|
iid.last_value = iid.last_value - 2 iid.last_value = iid.last_value - 2
......
...@@ -84,15 +84,32 @@ describe Issue do ...@@ -84,15 +84,32 @@ describe Issue do
end end
end end
describe '#closed_at' do describe '#close' do
it 'sets closed_at to Time.now when issue is closed' do subject(:issue) { create(:issue, state: 'opened') }
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
end end
......
...@@ -9,6 +9,10 @@ module MigrationsHelpers ...@@ -9,6 +9,10 @@ module MigrationsHelpers
Class.new(active_record_base) do Class.new(active_record_base) do
self.table_name = name self.table_name = name
self.inheritance_column = :_type_disabled self.inheritance_column = :_type_disabled
def self.name
table_name.singularize.camelcase
end
end 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