Commit 13a267b9 authored by Jarka Kadlecová's avatar Jarka Kadlecová

Revert "Merge branch '5481-epic-todos' into 'master'"

This reverts commit 79d64720, reversing
changes made to 41c3e005.

# Conflicts:
#	app/models/group.rb
#	ee/app/services/ee/todo_service.rb
#	ee/spec/features/epics/todo_spec.rb
parent 78e0fa3e
......@@ -39,7 +39,6 @@ export default class Todos {
}
initFilters() {
this.initFilterDropdown($('.js-group-search'), 'group_id', ['text']);
this.initFilterDropdown($('.js-project-search'), 'project_id', ['text']);
this.initFilterDropdown($('.js-type-search'), 'type');
this.initFilterDropdown($('.js-action-search'), 'action_id');
......@@ -54,16 +53,7 @@ export default class Todos {
filterable: searchFields ? true : false,
search: { fields: searchFields },
data: $dropdown.data('data'),
clicked: () => {
const $formEl = $dropdown.closest('form.filter-form');
const mutexDropdowns = {
group_id: 'project_id',
project_id: 'group_id',
};
$formEl.find(`input[name="${mutexDropdowns[fieldName]}"]`).remove();
$formEl.submit();
},
clicked: () => $dropdown.closest('form.filter-form').submit(),
});
}
......
<script>
import { __ } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
const MARK_TEXT = __('Mark todo as done');
const TODO_TEXT = __('Add todo');
export default {
directives: {
tooltip,
},
components: {
Icon,
LoadingIcon,
},
props: {
issuableId: {
type: Number,
required: true,
},
issuableType: {
type: String,
required: true,
},
isTodo: {
type: Boolean,
required: false,
default: true,
},
isActionActive: {
type: Boolean,
required: false,
default: false,
},
collapsed: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
buttonClasses() {
return this.collapsed ?
'btn-blank btn-todo sidebar-collapsed-icon dont-change-state' :
'btn btn-default btn-todo issuable-header-btn float-right';
},
buttonLabel() {
return this.isTodo ? MARK_TEXT : TODO_TEXT;
},
collapsedButtonIconClasses() {
return this.isTodo ? 'todo-undone' : '';
},
collapsedButtonIcon() {
return this.isTodo ? 'todo-done' : 'todo-add';
},
},
methods: {
handleButtonClick() {
this.$emit('toggleTodo');
},
},
};
</script>
<template>
<button
v-tooltip
:class="buttonClasses"
:title="buttonLabel"
:aria-label="buttonLabel"
:data-issuable-id="issuableId"
:data-issuable-type="issuableType"
type="button"
data-container="body"
data-placement="left"
data-boundary="viewport"
@click="handleButtonClick"
>
<icon
v-show="collapsed"
:css-classes="collapsedButtonIconClasses"
:name="collapsedButtonIcon"
/>
<span
v-show="!collapsed"
class="issuable-todo-inner"
>
{{ buttonLabel }}
</span>
<loading-icon
v-show="isActionActive"
:inline="true"
/>
</button>
</template>
......@@ -12,11 +12,6 @@ export default {
type: Boolean,
required: true,
},
cssClasses: {
type: String,
required: false,
default: '',
},
},
computed: {
tooltipLabel() {
......@@ -35,12 +30,10 @@ export default {
<button
v-tooltip
:title="tooltipLabel"
:class="cssClasses"
type="button"
class="btn btn-blank gutter-toggle btn-sidebar-action"
data-container="body"
data-placement="left"
data-boundary="viewport"
@click="toggle"
>
<i
......
......@@ -462,7 +462,6 @@
.todo-undone {
color: $gl-link-color;
fill: $gl-link-color;
}
.author {
......
......@@ -174,18 +174,6 @@
}
}
@include media-breakpoint-down(lg) {
.todos-filters {
.filter-categories {
width: 75%;
.filter-item {
margin-bottom: 10px;
}
}
}
}
@include media-breakpoint-down(xs) {
.todo {
.avatar {
......@@ -211,10 +199,6 @@
}
.todos-filters {
.filter-categories {
width: auto;
}
.dropdown-menu-toggle {
width: 100%;
}
......
module TodosActions
extend ActiveSupport::Concern
def create
todo = TodoService.new.mark_todo(issuable, current_user)
render json: {
count: TodosFinder.new(current_user, state: :pending).execute.count,
delete_path: dashboard_todo_path(todo)
}
end
end
......@@ -70,7 +70,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
end
def todo_params
params.permit(:action_id, :author_id, :project_id, :type, :sort, :state, :group_id)
params.permit(:action_id, :author_id, :project_id, :type, :sort, :state)
end
def redirect_out_of_range(todos)
......
class Projects::TodosController < Projects::ApplicationController
include Gitlab::Utils::StrongMemoize
include TodosActions
before_action :authenticate_user!, only: [:create]
def create
todo = TodoService.new.mark_todo(issuable, current_user)
render json: {
count: TodosFinder.new(current_user, state: :pending).execute.count,
delete_path: dashboard_todo_path(todo)
}
end
private
def issuable
strong_memoize(:issuable) do
@issuable ||= begin
case params[:issuable_type]
when "issue"
IssuesFinder.new(current_user, project_id: @project.id).find(params[:issuable_id])
......
......@@ -15,7 +15,6 @@
class TodosFinder
prepend FinderWithCrossProjectAccess
include FinderMethods
include Gitlab::Utils::StrongMemoize
requires_cross_project_access unless: -> { project? }
......@@ -35,11 +34,9 @@ class TodosFinder
items = by_author(items)
items = by_state(items)
items = by_type(items)
items = by_group(items)
# Filtering by project HAS TO be the last because we use
# the project IDs yielded by the todos query thus far
items = by_project(items)
items = visible_to_user(items)
sort(items)
end
......@@ -85,10 +82,6 @@ class TodosFinder
params[:project_id].present?
end
def group?
params[:group_id].present?
end
def project
return @project if defined?(@project)
......@@ -107,14 +100,18 @@ class TodosFinder
@project
end
def group
strong_memoize(:group) do
Group.find(params[:group_id])
def project_ids(items)
ids = items.except(:order).select(:project_id)
if Gitlab::Database.mysql?
# To make UPDATE work on MySQL, wrap it in a SELECT with an alias
ids = Todo.except(:order).select('*').from("(#{ids.to_sql}) AS t")
end
ids
end
def type?
type.present? && %w(Issue MergeRequest Epic).include?(type)
type.present? && %w(Issue MergeRequest).include?(type)
end
def type
......@@ -151,37 +148,12 @@ class TodosFinder
def by_project(items)
if project?
items = items.where(project: project)
end
items
end
items.where(project: project)
else
projects = Project.public_or_visible_to_user(current_user)
def by_group(items)
if group?
groups = group.self_and_descendants
items = items.where(
'project_id IN (?) OR group_id IN (?)',
Project.where(group: groups).select(:id),
groups.select(:id)
)
items.joins(:project).merge(projects)
end
items
end
def visible_to_user(items)
projects = Project.public_or_visible_to_user(current_user)
groups = Group.public_or_visible_to_user(current_user)
items
.joins('LEFT JOIN namespaces ON namespaces.id = todos.group_id')
.joins('LEFT JOIN projects ON projects.id = todos.project_id')
.where(
'project_id IN (?) OR group_id IN (?)',
projects.select(:id),
groups.select(:id)
)
end
def by_state(items)
......
......@@ -133,19 +133,6 @@ module IssuablesHelper
end
end
def group_dropdown_label(group_id, default_label)
return default_label if group_id.nil?
return "Any group" if group_id == "0"
group = ::Group.find_by(id: group_id)
if group
group.full_name
else
default_label
end
end
def milestone_dropdown_label(milestone_title, default_label = "Milestone")
title =
case milestone_title
......
module TodosHelper
prepend EE::NotesHelper
def todos_pending_count
@todos_pending_count ||= current_user.todos_pending_count
end
......@@ -45,7 +43,7 @@ module TodosHelper
project_commit_path(todo.project,
todo.target, anchor: anchor)
else
path = [todo.parent, todo.target]
path = [todo.project.namespace.becomes(Namespace), todo.project, todo.target]
path.unshift(:pipelines) if todo.build_failed?
......@@ -169,12 +167,4 @@ module TodosHelper
def show_todo_state?(todo)
(todo.target.is_a?(MergeRequest) || todo.target.is_a?(Issue)) && %w(closed merged).include?(todo.target.state)
end
def todo_group_options
groups = current_user.authorized_groups.map do |group|
{ id: group.id, text: group.full_name }
end
groups.unshift({ id: '', text: 'Any Group' }).to_json
end
end
......@@ -253,12 +253,6 @@ module Issuable
opened?
end
def overdue?
return false unless respond_to?(:due_date)
due_date.try(:past?) || false
end
def user_notes_count
if notes.loaded?
# Use the in-memory association to select and count to avoid hitting the db
......
......@@ -41,8 +41,6 @@ class Group < Namespace
has_many :boards
has_many :badges, class_name: 'GroupBadge'
has_many :todos
accepts_nested_attributes_for :variables, allow_destroy: true
validate :visibility_level_allowed_by_projects
......@@ -86,12 +84,6 @@ class Group < Namespace
where(id: user.authorized_groups.select(:id).reorder(nil))
end
def public_or_visible_to_user(user)
where('id IN (?) OR namespaces.visibility_level IN (?)',
user.authorized_groups.select(:id),
Gitlab::VisibilityLevel.levels_for_user(user))
end
def select_for_project_authorization
if current_scope.joins_values.include?(:shared_projects)
joins('INNER JOIN namespaces project_namespace ON project_namespace.id = projects.namespace_id')
......
......@@ -299,6 +299,10 @@ class Issue < ActiveRecord::Base
user ? readable_by?(user) : publicly_visible?
end
def overdue?
due_date.try(:past?) || false
end
def check_for_spam?
project.public? && (title_changed? || description_changed?)
end
......
......@@ -237,10 +237,6 @@ class Note < ActiveRecord::Base
!for_personal_snippet?
end
def for_issuable?
for_issue? || for_merge_request?
end
def skip_project_check?
!for_project_noteable?
end
......
class Todo < ActiveRecord::Base
prepend EE::Todo
include Sortable
ASSIGNED = 1
......@@ -23,18 +22,15 @@ class Todo < ActiveRecord::Base
belongs_to :author, class_name: "User"
belongs_to :note
belongs_to :project
belongs_to :group
belongs_to :target, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :user
delegate :name, :email, to: :author, prefix: true, allow_nil: true
validates :action, :target_type, :user, presence: true
validates :action, :project, :target_type, :user, presence: true
validates :author, presence: true
validates :target_id, presence: true, unless: :for_commit?
validates :commit_id, presence: true, if: :for_commit?
validates :project, presence: true, unless: :group_id
validates :group, presence: true, unless: :project_id
scope :pending, -> { with_state(:pending) }
scope :done, -> { with_state(:done) }
......@@ -48,7 +44,7 @@ class Todo < ActiveRecord::Base
state :done
end
after_save :keep_around_commit, if: :commit_id
after_save :keep_around_commit
class << self
# Priority sorting isn't displayed in the dropdown, because we don't show
......@@ -83,10 +79,6 @@ class Todo < ActiveRecord::Base
end
end
def parent
project
end
def unmergeable?
action == UNMERGEABLE
end
......
......@@ -262,15 +262,15 @@ class TodoService
end
end
def create_mention_todos(parent, target, author, note = nil, skip_users = [])
def create_mention_todos(project, target, author, note = nil, skip_users = [])
# Create Todos for directly addressed users
directly_addressed_users = filter_directly_addressed_users(parent, note || target, author, skip_users)
attributes = attributes_for_todo(parent, target, author, Todo::DIRECTLY_ADDRESSED, note)
directly_addressed_users = filter_directly_addressed_users(project, note || target, author, skip_users)
attributes = attributes_for_todo(project, target, author, Todo::DIRECTLY_ADDRESSED, note)
create_todos(directly_addressed_users, attributes)
# Create Todos for mentioned users
mentioned_users = filter_mentioned_users(parent, note || target, author, skip_users)
attributes = attributes_for_todo(parent, target, author, Todo::MENTIONED, note)
mentioned_users = filter_mentioned_users(project, note || target, author, skip_users)
attributes = attributes_for_todo(project, target, author, Todo::MENTIONED, note)
create_todos(mentioned_users, attributes)
end
......@@ -301,36 +301,36 @@ class TodoService
def attributes_for_todo(project, target, author, action, note = nil)
attributes_for_target(target).merge!(
project_id: project&.id,
project_id: project.id,
author_id: author.id,
action: action,
note: note
)
end
def filter_todo_users(users, parent, target)
reject_users_without_access(users, parent, target).uniq
def filter_todo_users(users, project, target)
reject_users_without_access(users, project, target).uniq
end
def filter_mentioned_users(parent, target, author, skip_users = [])
def filter_mentioned_users(project, target, author, skip_users = [])
mentioned_users = target.mentioned_users(author) - skip_users
filter_todo_users(mentioned_users, parent, target)
filter_todo_users(mentioned_users, project, target)
end
def filter_directly_addressed_users(parent, target, author, skip_users = [])
def filter_directly_addressed_users(project, target, author, skip_users = [])
directly_addressed_users = target.directly_addressed_users(author) - skip_users
filter_todo_users(directly_addressed_users, parent, target)
filter_todo_users(directly_addressed_users, project, target)
end
def reject_users_without_access(users, parent, target)
if target.is_a?(Note) && target.for_issuable?
def reject_users_without_access(users, project, target)
if target.is_a?(Note) && (target.for_issue? || target.for_merge_request?)
target = target.noteable
end
if target.is_a?(Issuable)
select_users(users, :"read_#{target.to_ability_name}", target)
else
select_users(users, :read_project, parent)
select_users(users, :read_project, project)
end
end
......
......@@ -30,33 +30,27 @@
.todos-filters
.row-content-block.second-block
= form_tag todos_filter_path(without: [:project_id, :author_id, :type, :action_id]), method: :get, class: 'filter-form d-sm-flex' do
.filter-categories.flex-fill
.filter-item.inline
- if params[:group_id].present?
= hidden_field_tag(:group_id, params[:group_id])
= dropdown_tag(group_dropdown_label(params[:group_id], 'Group'), options: { toggle_class: 'js-group-search js-filter-submit', title: 'Filter by group', filter: true, filterInput: 'input#group-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-group js-filter-submit',
placeholder: 'Search groups', data: { data: todo_group_options, default_label: 'Group', display: 'static' } })
.filter-item.inline
- if params[:project_id].present?
= hidden_field_tag(:project_id, params[:project_id])
= dropdown_tag(project_dropdown_label(params[:project_id], 'Project'), options: { toggle_class: 'js-project-search js-filter-submit', title: 'Filter by project', filter: true, filterInput: 'input#project-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit',
placeholder: 'Search projects', data: { data: todo_projects_options, default_label: 'Project', display: 'static' } })
.filter-item.inline
- if params[:author_id].present?
= hidden_field_tag(:author_id, params[:author_id])
= dropdown_tag(user_dropdown_label(params[:author_id], 'Author'), options: { toggle_class: 'js-user-search js-filter-submit js-author-search', title: 'Filter by author', filter: true, filterInput: 'input#author-search', dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit',
placeholder: 'Search authors', data: { any_user: 'Any Author', first_user: (current_user.username if current_user), project_id: (@project.id if @project), selected: params[:author_id], field_name: 'author_id', default_label: 'Author', todo_filter: true, todo_state_filter: params[:state] || 'pending' } })
.filter-item.inline
- if params[:type].present?
= hidden_field_tag(:type, params[:type])
= dropdown_tag(todo_types_dropdown_label(params[:type], 'Type'), options: { toggle_class: 'js-type-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-type js-filter-submit',
data: { data: todo_types_options, default_label: 'Type' } })
.filter-item.inline.actions-filter
- if params[:action_id].present?
= hidden_field_tag(:action_id, params[:action_id])
= dropdown_tag(todo_actions_dropdown_label(params[:action_id], 'Action'), options: { toggle_class: 'js-action-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-action js-filter-submit',
data: { data: todo_actions_options, default_label: 'Action' } })
= form_tag todos_filter_path(without: [:project_id, :author_id, :type, :action_id]), method: :get, class: 'filter-form' do
.filter-item.inline
- if params[:project_id].present?
= hidden_field_tag(:project_id, params[:project_id])
= dropdown_tag(project_dropdown_label(params[:project_id], 'Project'), options: { toggle_class: 'js-project-search js-filter-submit', title: 'Filter by project', filter: true, filterInput: 'input#project-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit',
placeholder: 'Search projects', data: { data: todo_projects_options, default_label: 'Project', display: 'static' } })
.filter-item.inline
- if params[:author_id].present?
= hidden_field_tag(:author_id, params[:author_id])
= dropdown_tag(user_dropdown_label(params[:author_id], 'Author'), options: { toggle_class: 'js-user-search js-filter-submit js-author-search', title: 'Filter by author', filter: true, filterInput: 'input#author-search', dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit',
placeholder: 'Search authors', data: { any_user: 'Any Author', first_user: (current_user.username if current_user), project_id: (@project.id if @project), selected: params[:author_id], field_name: 'author_id', default_label: 'Author', todo_filter: true, todo_state_filter: params[:state] || 'pending' } })
.filter-item.inline
- if params[:type].present?
= hidden_field_tag(:type, params[:type])
= dropdown_tag(todo_types_dropdown_label(params[:type], 'Type'), options: { toggle_class: 'js-type-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-type js-filter-submit',
data: { data: todo_types_options, default_label: 'Type' } })
.filter-item.inline.actions-filter
- if params[:action_id].present?
= hidden_field_tag(:action_id, params[:action_id])
= dropdown_tag(todo_actions_dropdown_label(params[:action_id], 'Action'), options: { toggle_class: 'js-action-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-action js-filter-submit',
data: { data: todo_actions_options, default_label: 'Action' } })
.filter-item.sort-filter
.dropdown
%button.dropdown-menu-toggle.dropdown-menu-toggle-sort{ type: 'button', 'data-toggle' => 'dropdown' }
......
......@@ -110,8 +110,6 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
end
end
resources :todos, only: [:create]
# On CE only index and show are needed
resources :boards, only: [:index, :show, :create, :update, :destroy]
......
class AddGroupToTodos < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_column :todos, :group_id, :integer
add_concurrent_foreign_key :todos, :namespaces, column: :group_id, on_delete: :cascade
add_concurrent_index :todos, :group_id
change_column_null :todos, :project_id, true
end
def down
return unless group_id_exists?
remove_foreign_key :todos, column: :group_id
remove_index :todos, :group_id if index_exists?(:todos, :group_id)
remove_column :todos, :group_id
execute "DELETE FROM todos WHERE project_id IS NULL"
change_column_null :todos, :project_id, false
end
private
def group_id_exists?
column_exists?(:todos, :group_id)
end
end
......@@ -2528,7 +2528,7 @@ ActiveRecord::Schema.define(version: 20180704204006) do
create_table "todos", force: :cascade do |t|
t.integer "user_id", null: false
t.integer "project_id"
t.integer "project_id", null: false
t.integer "target_id"
t.string "target_type", null: false
t.integer "author_id", null: false
......@@ -2538,12 +2538,10 @@ ActiveRecord::Schema.define(version: 20180704204006) do
t.datetime "updated_at"
t.integer "note_id"
t.string "commit_id"
t.integer "group_id"
end
add_index "todos", ["author_id"], name: "index_todos_on_author_id", using: :btree
add_index "todos", ["commit_id"], name: "index_todos_on_commit_id", using: :btree
add_index "todos", ["group_id"], name: "index_todos_on_group_id", using: :btree
add_index "todos", ["note_id"], name: "index_todos_on_note_id", using: :btree
add_index "todos", ["project_id"], name: "index_todos_on_project_id", using: :btree
add_index "todos", ["target_type", "target_id"], name: "index_todos_on_target_type_and_target_id", using: :btree
......@@ -3004,7 +3002,6 @@ ActiveRecord::Schema.define(version: 20180704204006) do
add_foreign_key "term_agreements", "users", on_delete: :cascade
add_foreign_key "timelogs", "issues", name: "fk_timelogs_issues_issue_id", on_delete: :cascade
add_foreign_key "timelogs", "merge_requests", name: "fk_timelogs_merge_requests_merge_request_id", on_delete: :cascade
add_foreign_key "todos", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "todos", "notes", name: "fk_91d1f47b13", on_delete: :cascade
add_foreign_key "todos", "projects", name: "fk_45054f9c45", on_delete: :cascade
add_foreign_key "todos", "users", column: "author_id", name: "fk_ccf0373936", on_delete: :cascade
......
......@@ -210,71 +210,3 @@ DELETE /groups/:id/epics/:epic_iid
```bash
curl --header DELETE "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/1/epics/5?title=New%20Title
```
## Create a todo
Manually creates a todo for the current user on an epic. If
there already exists a todo for the user on that epic, status code `304` is
returned.
```
POST /groups/:id/epics/:epic_iid/todo
```
| Attribute | Type | Required | Description |
|-------------|---------|----------|--------------------------------------|
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `epic_iid ` | integer | yes | The internal ID of a group's epic |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/1/epics/5/todo
```
Example response:
```json
{
"id": 112,
"group": {
"id": 1,
"name": "Gitlab",
"path": "gitlab",
"kind": "group",
"full_path": "base/gitlab",
"parent_id": null
},
"author": {
"name": "Administrator",
"username": "root",
"id": 1,
"state": "active",
"avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
"web_url": "https://gitlab.example.com/root"
},
"action_name": "marked",
"target_type": "epic",
"target": {
"id": 30,
"iid": 5,
"group_id": 1,
"title": "Ea cupiditate dolores ut vero consequatur quasi veniam voluptatem et non.",
"description": "Molestias dolorem eos vitae expedita impedit necessitatibus quo voluptatum.",
"author":{
"id": 7,
"name": "Pamella Huel",
"username": "arnita",
"state": "active",
"avatar_url": "http://www.gravatar.com/avatar/a2f5c6fcef64c9c69cb8779cb292be1b?s=80&d=identicon",
"web_url": "http://localhost:3001/arnita"
},
"start_date": null,
"end_date": null,
"created_at": "2018-01-21T06:21:13.165Z",
"updated_at": "2018-01-22T12:41:41.166Z"
},
"target_url": "https://gitlab.example.com/groups/epics/5",
"body": "Vel voluptas atque dicta mollitia adipisci qui at.",
"state": "pending",
"created_at": "2016-07-01T11:09:13.992Z"
}
```
......@@ -18,7 +18,6 @@ Parameters:
| `action` | string | no | The action to be filtered. Can be `assigned`, `mentioned`, `build_failed`, `marked`, `approval_required`, `unmergeable` or `directly_addressed`. |
| `author_id` | integer | no | The ID of an author |
| `project_id` | integer | no | The ID of a project |
| `group_id` | integer | no | The ID of a group |
| `state` | string | no | The state of the todo. Can be either `pending` or `done` |
| `type` | string | no | The type of a todo. Can be either `Issue` or `MergeRequest` |
......
......@@ -26,8 +26,8 @@ will still be shown in the body of the _To do_ tab.
A Todo appears in your Todos dashboard when:
- an issue or merge request is assigned to you,
- you are `@mentioned` in an issue, merge request or epic, be it the description of
the issue/merge request/epic or in a comment,
- you are `@mentioned` in an issue or merge request, be it the description of
the issue/merge request or in a comment,
- you are `@mentioned` in a comment on a commit,
- a job in the CI pipeline running for your merge request failed, but this
job is not allowed to fail.
......@@ -60,14 +60,14 @@ for filtering; otherwise, they appear as normal.
### Manually creating a Todo
You can also add an issue, merge request or epic to your Todos dashboard by clicking
the "Add todo" button in the issue, merge request or epic sidebar.
You can also add an issue or merge request to your Todos dashboard by clicking
the "Add todo" button in the issue or merge request sidebar.
![Adding a Todo from the issuable sidebar](img/todos_add_todo_sidebar.png)
## Marking a Todo as done
Any action to the corresponding issue, merge request or epic will mark your Todo as
Any action to the corresponding issue or merge request will mark your Todo as
**Done**. Actions that dismiss Todos include:
- changing the assignee
......@@ -81,10 +81,10 @@ Todos are personal, and they're only marked as done if the action is coming from
you. If you close the issue or merge request, your Todo will automatically
be marked as done.
If someone else closes, merges, or takes action on the issue, epic or merge
If someone else closes, merges, or takes action on the issue or merge
request, your Todo will remain pending. This prevents other users from closing issues without you being notified.
There is just one Todo per issue, epic or merge request, so mentioning a user a
There is just one Todo per issue or merge request, so mentioning a user a
hundred times in an issue will only trigger one Todo.
---
......@@ -94,7 +94,7 @@ corresponding **Done** button, and it will disappear from your Todo list.
![A Todo in the Todos dashboard](img/todo_list_item.png)
A Todo can also be marked as done from the issue, merge request or epic sidebar using
A Todo can also be marked as done from the issue or merge request sidebar using
the "Mark todo as done" button.
![Mark todo as done from the issuable sidebar](img/todos_mark_done_sidebar.png)
......@@ -109,9 +109,8 @@ There are four kinds of filters you can use on your Todos dashboard.
| Filter | Description |
| ------- | ----------- |
| Project | Filter by project |
| Group | Filter by group |
| Author | Filter by the author that triggered the Todo |
| Type | Filter by issue, merge request or epic |
| Type | Filter by issue or merge request |
| Action | Filter by the action that triggered the Todo |
You can also filter by more than one of these at the same time. The possible Actions are `Any Action`, `Assigned`, `Mentioned`, `Added`, `Pipelines`, and `Directly Addressed`, [as described above](#what-triggers-a-todo).
......
......@@ -16,10 +16,6 @@
relatedIssuesRoot,
},
props: {
epicId: {
type: Number,
required: true,
},
endpoint: {
type: String,
required: true,
......@@ -102,10 +98,6 @@
type: Boolean,
required: true,
},
todoExists: {
type: Boolean,
required: true,
},
namespace: {
type: String,
required: false,
......@@ -119,15 +111,6 @@
type: String,
required: true,
},
todoPath: {
type: String,
required: true,
},
todoDeletePath: {
type: String,
required: false,
default: '',
},
labelsWebUrl: {
type: String,
required: true,
......@@ -187,7 +170,6 @@
/>
</div>
<epic-sidebar
:epic-id="epicId"
:endpoint="endpoint"
:editable="canUpdate"
:initial-start-date="startDate"
......@@ -195,13 +177,10 @@
:initial-labels="labels"
:initial-participants="participants"
:initial-subscribed="subscribed"
:initial-todo-exists="todoExists"
:namespace="namespace"
:update-path="updateEndpoint"
:labels-path="labelsPath"
:toggle-subscription-path="toggleSubscriptionPath"
:todo-path="todoPath"
:todo-delete-path="todoDeletePath"
:labels-web-url="labelsWebUrl"
:epics-web-url="epicsWebUrl"
/>
......
import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import EpicShowApp from './components/epic_show_app.vue';
export default () => {
const el = document.querySelector('#epic-show-app');
const metaData = convertObjectPropsToCamelCase(JSON.parse(el.dataset.meta));
const metaData = JSON.parse(el.dataset.meta);
const initialData = JSON.parse(el.dataset.initial);
const props = Object.assign({}, initialData, metaData, el.dataset);
// Convert backend casing to match frontend style guide
props.startDate = props.start_date;
props.endDate = props.end_date;
return new Vue({
el,
components: {
......
import axios from '~/lib/utils/axios_utils';
export default class SidebarService {
constructor({ endpoint, subscriptionEndpoint, todoPath }) {
constructor(endpoint, subscriptionEndpoint) {
this.endpoint = endpoint;
this.subscriptionEndpoint = subscriptionEndpoint;
this.todoPath = todoPath;
}
updateStartDate(startDate) {
......@@ -18,16 +17,4 @@ export default class SidebarService {
toggleSubscribed() {
return axios.post(this.subscriptionEndpoint);
}
addTodo(epicId) {
return axios.post(this.todoPath, {
issuable_id: epicId,
issuable_type: 'epic',
});
}
// eslint-disable-next-line class-methods-use-this
deleteTodo(todoDeletePath) {
return axios.delete(todoDeletePath);
}
}
import { parsePikadayDate } from '~/lib/utils/datefix';
export default class SidebarStore {
constructor({ startDate, endDate, subscribed, todoExists, todoDeletePath }) {
constructor({ startDate, endDate, subscribed }) {
this.startDate = startDate;
this.endDate = endDate;
this.subscribed = subscribed;
this.todoExists = todoExists;
this.todoDeletePath = todoDeletePath;
}
get startDateTime() {
......@@ -20,12 +18,4 @@ export default class SidebarStore {
setSubscribed(subscribed) {
this.subscribed = subscribed;
}
setTodoExists(todoExists) {
this.todoExists = todoExists;
}
setTodoDeletePath(deletePath) {
this.todoDeletePath = deletePath;
}
}
class Groups::TodosController < Groups::ApplicationController
include Gitlab::Utils::StrongMemoize
include TodosActions
before_action :authenticate_user!, only: [:create]
private
def issuable
strong_memoize(:epic) do
next if params[:issuable_type] != 'epic'
@group.epics.find_by(id: params[:issuable_id])
end
end
end
module EE
module TodosHelper
extend ::Gitlab::Utils::Override
override :todo_types_options
def todo_types_options
super << { id: 'Epic', text: 'Epic' }
end
end
end
......@@ -2,10 +2,8 @@ module EpicsHelper
def epic_show_app_data(epic, opts)
author = epic.author
group = epic.group
todo = issuable_todo(epic)
epic_meta = {
epic_id: epic.id,
created: epic.created_at,
author: {
name: author.name,
......@@ -13,14 +11,10 @@ module EpicsHelper
username: "@#{author.username}",
src: opts[:author_icon]
},
todo_exists: todo.present?,
todo_path: group_todos_path(group),
start_date: epic.start_date,
end_date: epic.end_date
}
epic_meta[:todo_delete_path] = dashboard_todo_path(todo) if todo.present?
participants = UserSerializer.new.represent(epic.participants)
initial = opts[:initial].merge(labels: epic.labels,
participants: participants,
......
......@@ -34,6 +34,11 @@ module EE
!for_epic? && super
end
override :can_create_todo?
def can_create_todo?
!for_epic? && super
end
override :etag_key
def etag_key
if for_epic?
......@@ -57,11 +62,6 @@ module EE
super.merge(banzai_context_params)
end
override :for_issuable?
def for_issuable?
for_epic? || super
end
private
def banzai_context_params
......
module EE
module Todo
extend ::Gitlab::Utils::Override
override :parent
def parent
project || group
end
end
end
......@@ -19,27 +19,8 @@ module EE
super
end
def new_epic(epic, current_user)
create_mention_todos(nil, epic, current_user)
end
def update_epic(epic, current_user, skip_users = [])
create_mention_todos(nil, epic, current_user, nil, skip_users)
end
private
override :attributes_for_target
def attributes_for_target(target)
attributes = super
if target.is_a?(Epic)
attributes[:group_id] = target.group_id
end
attributes
end
def create_approval_required_todos(merge_request, approvers, author)
attributes = attributes_for_todo(merge_request.project, merge_request, author, ::Todo::APPROVAL_REQUIRED)
create_todos(approvers.map(&:user), attributes)
......
......@@ -2,20 +2,6 @@ module Epics
class UpdateService < Epics::BaseService
def execute(epic)
update(epic)
epic
end
def handle_changes(epic, options)
old_associations = options.fetch(:old_associations, {})
old_mentioned_users = old_associations.fetch(:mentioned_users, [])
old_labels = old_associations.fetch(:labels, [])
if has_changes?(epic, old_labels: old_labels)
todo_service.mark_pending_todos_as_done(epic, current_user)
end
todo_service.update_epic(epic, current_user, old_mentioned_users)
end
end
end
---
title: Add support for todos on epics
merge_request: 6142
author:
type: added
......@@ -121,16 +121,6 @@ module EE
end
end
module Todo
extend ActiveSupport::Concern
def todo_target_class(target_type)
::EE::API::Entities.const_get(target_type, false)
rescue NameError
super
end
end
########################
# EE-specific entities #
########################
......
module EE
module API
module Todos
extend ActiveSupport::Concern
prepended do
helpers do
def epic
@epic ||= user_group.epics.find_by(iid: params[:epic_iid])
end
def authorize_can_read!
authorize!(:read_epic, epic)
end
end
resource :groups, requirements: ::API::API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Create a todo on an epic' do
success ::API::Entities::Todo
end
params do
requires :epic_iid, type: Integer, desc: 'The IID of an epic'
end
post ":id/epics/:epic_iid/todo" do
authorize_can_read!
todo = ::TodoService.new.mark_todo(epic, current_user).first
if todo
present todo, with: ::API::Entities::Todo, current_user: current_user
else
not_modified!
end
end
end
end
end
end
end
require 'spec_helper'
describe Groups::TodosController do
let(:user) { create(:user) }
let(:group) { create(:group, :private) }
let(:epic) { create(:epic, group: group) }
let(:parent) { group }
describe 'POST create' do
def post_create
post :create,
group_id: group,
issuable_id: epic.id,
issuable_type: 'epic',
format: :json
end
it_behaves_like 'todos actions'
end
end
require 'rails_helper'
describe 'Manually create a todo item from epic', :js do
let(:group) { create(:group) }
let(:epic) { create(:epic, group: group) }
let(:user) { create(:user)}
before do
stub_licensed_features(epics: true)
sign_in(user)
visit group_epic_path(group, epic)
end
it 'creates todo when clicking button' do
page.within '.issuable-sidebar' do
click_button 'Add todo'
expect(page).to have_content 'Mark todo as done'
end
page.within '.header-content .todos-count' do
expect(page).to have_content '1'
end
end
it 'marks a todo as done' do
page.within '.issuable-sidebar' do
click_button 'Add todo'
end
expect(page).to have_selector('.todos-count', visible: true)
page.within '.header-content .todos-count' do
expect(page).to have_content '1'
end
page.within '.issuable-sidebar' do
click_button 'Mark todo as done'
end
expect(page).to have_selector('.todos-count', visible: false)
end
end
......@@ -57,32 +57,6 @@ describe 'Update Epic', :js do
expect(find('.issuable-details .description')).to have_content('New epic description')
end
it 'creates a todo only for mentioned users' do
mentioned = create(:user)
fill_in 'issue-description', with: "FYI #{mentioned.to_reference}"
click_button 'Save changes'
expect(find('.issuable-details h2.title')).to have_content('title')
visit dashboard_todos_path
expect(page).to have_selector('.todos-list .todo', count: 0)
sign_in(mentioned)
visit dashboard_todos_path
page.within '.header-content .todos-count' do
expect(page).to have_content '1'
end
expect(page).to have_selector('.todos-list .todo', count: 1)
within first('.todo') do
expect(page).to have_content "epic #{epic.to_reference(full: true)}"
end
end
it 'edits full screen' do
page.within('.detail-page-description') { find('.js-zen-enter').click }
......
......@@ -15,7 +15,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 start_date end_date epic_id todo_exists todo_path])
expect(meta_data.keys).to match_array(%w[created author start_date end_date])
expect(meta_data['author']).to eq({
'name' => user.name,
'url' => "/#{user.username}",
......
require 'spec_helper'
describe API::Todos do
let(:group) { create(:group) }
let(:user) { create(:user) }
let(:epic) { create(:epic, group: group) }
subject { post api("/groups/#{group.id}/epics/#{epic.iid}/todo", user) }
describe 'POST :id/epics/:epic_iid/todo' do
context 'when epics feature is disabled' do
it 'returns 403 forbidden error' do
subject
expect(response).to have_gitlab_http_status(403)
end
end
context 'when epics feature is enabled' do
before do
stub_licensed_features(epics: true)
end
it 'creates a todo on an epic' do
expect { subject }.to change { Todo.count }.by(1)
expect(response.status).to eq(201)
expect(json_response['project']).to be_nil
expect(json_response['group']).to be_a(Hash)
expect(json_response['author']).to be_a(Hash)
expect(json_response['target_type']).to eq('Epic')
expect(json_response['target']).to be_a(Hash)
expect(json_response['target_url']).to be_present
expect(json_response['body']).to be_present
expect(json_response['state']).to eq('pending')
expect(json_response['action_name']).to eq('marked')
expect(json_response['created_at']).to be_present
end
it 'returns 304 there already exist a todo on that epic' do
create(:todo, project: nil, group: group, user: user, target: epic)
subject
expect(response.status).to eq(304)
end
it 'returns 404 if the epic is not found' do
post api("/groups/#{group.id}/epics/9999/todo", user)
expect(response.status).to eq(403)
end
it 'returns an error if the epic is not accessible' do
group.update(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
subject
expect(response).to have_gitlab_http_status(404)
end
end
end
end
......@@ -6,9 +6,6 @@ describe Epics::UpdateService do
let(:epic) { create(:epic, group: group) }
describe '#execute' do
before do
stub_licensed_features(epics: true)
end
def update_epic(opts)
described_class.new(group, user, opts).execute(epic)
end
......@@ -59,61 +56,5 @@ describe Epics::UpdateService do
expect(note.noteable).to eq(epic)
end
end
context 'todos' do
before do
group.update(visibility: Gitlab::VisibilityLevel::PUBLIC)
end
context 'creating todos' do
let(:mentioned1) { create(:user) }
let(:mentioned2) { create(:user) }
before do
epic.update(description: "FYI: #{mentioned1.to_reference}")
end
it 'creates todos for only newly mentioned users' do
expect do
update_epic(description: "FYI: #{mentioned1.to_reference} #{mentioned2.to_reference}")
end.to change { Todo.count }.by(1)
end
end
context 'adding a label' do
let(:label) { create(:group_label, group: group) }
let(:user2) { create(:user) }
let!(:todo1) do
create(:todo, :mentioned, :pending,
target: epic,
group: group,
project: nil,
author: user,
user: user)
end
let!(:todo2) do
create(:todo, :mentioned, :pending,
target: epic,
group: group,
project: nil,
author: user2,
user: user2)
end
before do
group.add_developer(user)
update_epic(label_ids: [label.id])
end
it 'marks todo as done for a user who added a label' do
expect(todo1.reload.state).to eq('done')
end
it 'does not mark todos as done for other users' do
expect(todo2.reload.state).to eq('pending')
end
end
end
end
end
require 'spec_helper'
describe TodoService do
describe 'Epics' do
let(:author) { create(:user, username: 'author') }
let(:non_member) { create(:user, username: 'non_member') }
let(:member) { create(:user, username: 'member') }
let(:guest) { create(:user, username: 'guest') }
let(:admin) { create(:admin, username: 'administrator') }
let(:john_doe) { create(:user, username: 'john_doe') }
let(:skipped) { create(:user, username: 'skipped') }
let(:skip_users) { [skipped] }
let(:users) { [author, non_member, member, guest, admin, john_doe, skipped] }
let(:mentions) { users.map(&:to_reference).join(' ') }
let(:combined_mentions) { member.to_reference + ", what do you think? cc: " + [guest, admin, skipped].map(&:to_reference).join(' ') }
let(:description_mentions) { "- [ ] Task 1\n- [ ] Task 2 FYI: #{mentions}" }
let(:description_directly_addressed) { "#{mentions}\n- [ ] Task 1\n- [ ] Task 2" }
let(:group) { create(:group) }
let(:epic) { create(:epic, group: group, author: author, description: description_mentions) }
let(:service) { described_class.new }
let(:todos_for) { [] }
let(:todos_not_for) { [] }
let(:target) { epic }
before do
stub_licensed_features(epics: true)
group.add_guest(guest)
group.add_developer(author)
group.add_developer(member)
end
shared_examples_for 'todos creation' do
it 'creates todos for users mentioned' do
if todos_for.count > 0
params = todo_params
.merge(user: todos_for)
.reverse_merge(target: target, project: nil, group: group, author: author, state: :pending)
expect { execute }
.to change { Todo.where(params).count }.from(0).to(todos_for.count)
end
end
it 'does not create todos for users not mentioned or without permissions' do
execute
params = todo_params
.reverse_merge(target: target, project: nil, group: group, author: author, state: :pending)
todos_not_for.each_with_index do |user, index|
expect(Todo.where(params.merge(user: user)).count)
.to eq(0), "expected not to create a todo for user '#{user.username}''"
end
end
end
context 'Epics' do
describe '#new_epic' do
let(:execute) { service.new_epic(epic, author) }
context 'when an epic belongs to a public group' do
context 'for mentioned users' do
let(:todo_params) { { action: Todo::MENTIONED } }
let(:todos_for) { users }
include_examples 'todos creation'
end
context 'for directly addressed users' do
before do
epic.update(description: description_directly_addressed)
end
let(:todo_params) { { action: Todo::DIRECTLY_ADDRESSED } }
let(:todos_for) { users }
include_examples 'todos creation'
end
context 'combined' do
before do
epic.update(description: combined_mentions)
end
context 'mentioned users' do
let(:todo_params) { { action: Todo::MENTIONED } }
let(:todos_for) { [guest, admin, skipped] }
include_examples 'todos creation'
end
context 'directly addressed users' do
let(:todo_params) { { action: Todo::DIRECTLY_ADDRESSED } }
let(:todos_for) { [member] }
include_examples 'todos creation'
end
end
end
context 'when an epic belongs to a private group' do
before do
group.update(visibility: Gitlab::VisibilityLevel::PRIVATE)
end
context 'for mentioned users' do
let(:todo_params) { { action: Todo::MENTIONED } }
let(:todos_for) { [member, author, guest, admin] }
let(:todos_not_for) { [non_member, john_doe, skipped] }
include_examples 'todos creation'
end
context 'for directly addressed users' do
before do
epic.update!(description: description_directly_addressed)
end
let(:todo_params) { { action: Todo::DIRECTLY_ADDRESSED } }
let(:todos_for) { [member, author, guest, admin] }
let(:todos_not_for) { [non_member, john_doe, skipped] }
include_examples 'todos creation'
end
end
context 'creates todos for group members when a group is mentioned' do
before do
epic.update(description: group.to_reference)
end
let(:todo_params) { { action: Todo::DIRECTLY_ADDRESSED } }
let(:todos_for) { [member, guest, author] }
let(:todos_not_for) { [non_member, admin, john_doe] }
include_examples 'todos creation'
end
end
describe '#update_epic' do
let(:execute) { service.update_epic(epic, author, skip_users) }
context 'for mentioned users' do
let(:todo_params) { { action: Todo::MENTIONED } }
let(:todos_for) { [author, non_member, member, guest, admin, john_doe] }
let(:todos_not_for) { [skipped] }
include_examples 'todos creation'
end
context 'for directly addressed users' do
before do
epic.update(description: description_directly_addressed)
end
let(:todo_params) { { action: Todo::DIRECTLY_ADDRESSED } }
let(:todos_for) { [author, non_member, member, guest, admin, john_doe] }
let(:todos_not_for) { [skipped] }
include_examples 'todos creation'
end
end
describe '#new_note' do
let!(:first_todo) do
create(:todo, :assigned,
user: john_doe, project: nil, group: group, target: epic, author: author)
end
let!(:second_todo) do
create(:todo, :assigned,
user: john_doe, project: nil, group: group, target: epic, author: author)
end
let(:note) { create(:note, noteable: epic, project: nil, author: john_doe, note: mentions) }
context 'when a note is created for an epic' do
it 'marks pending epic todos for the note author as done' do
service.new_note(note, john_doe)
expect(first_todo.reload).to be_done
expect(second_todo.reload).to be_done
end
it 'does not marka pending epic todos for the note author as done for system notes' do
system_note = create(:system_note, noteable: epic)
service.new_note(system_note, john_doe)
expect(first_todo.reload).to be_pending
expect(second_todo.reload).to be_pending
end
end
context 'mentions' do
let(:execute) { service.new_note(note, author) }
context 'for mentioned users' do
before do
note.update(note: description_mentions)
end
let(:todo_params) { { action: Todo::MENTIONED } }
let(:todos_for) { [author, non_member, member, guest, admin, skipped] }
let(:todos_not_for) { [john_doe] }
include_examples 'todos creation'
end
context 'for directly addressed users' do
before do
note.update(note: description_directly_addressed)
end
let(:todo_params) { { action: Todo::DIRECTLY_ADDRESSED } }
let(:todos_for) { [author, non_member, member, guest, admin, skipped] }
let(:todos_not_for) { [john_doe] }
include_examples 'todos creation'
end
context 'combined' do
before do
note.update(note: combined_mentions)
end
context 'mentioned users' do
let(:todo_params) { { action: Todo::MENTIONED } }
let(:todos_for) { [guest, admin, skipped] }
include_examples 'todos creation'
end
context 'directly addressed users' do
let(:todo_params) { { action: Todo::DIRECTLY_ADDRESSED } }
let(:todos_for) { [member] }
include_examples 'todos creation'
end
end
end
end
end
end
end
......@@ -803,33 +803,28 @@ module API
class Todo < Grape::Entity
expose :id
expose :project, using: Entities::ProjectIdentity, if: -> (todo, _) { todo.project_id }
expose :group, using: 'API::Entities::NamespaceBasic', if: -> (todo, _) { todo.group_id }
expose :project, using: Entities::BasicProjectDetails
expose :author, using: Entities::UserBasic
expose :action_name
expose :target_type
expose :target do |todo, options|
todo_target_class(todo.target_type).represent(todo.target, options)
Entities.const_get(todo.target_type).represent(todo.target, options)
end
expose :target_url do |todo, options|
target_type = todo.target_type.underscore
target_url = "#{todo.parent.class.to_s.underscore}_#{target_type}_url"
target_url = "namespace_project_#{target_type}_url"
target_anchor = "note_#{todo.note_id}" if todo.note_id?
Gitlab::Routing
.url_helpers
.public_send(target_url, todo.parent, todo.target, anchor: target_anchor) # rubocop:disable GitlabSecurity/PublicSend
.public_send(target_url, todo.project.namespace, todo.project, todo.target, anchor: target_anchor) # rubocop:disable GitlabSecurity/PublicSend
end
expose :body
expose :state
expose :created_at
def todo_target_class(target_type)
::API::Entities.const_get(target_type)
end
end
class NamespaceBasic < Grape::Entity
......@@ -1425,4 +1420,3 @@ API::Entities.prepend_entity(::API::Entities::Project, with: EE::API::Entities::
API::Entities.prepend_entity(::API::Entities::ProtectedRefAccess, with: EE::API::Entities::ProtectedRefAccess)
API::Entities.prepend_entity(::API::Entities::UserPublic, with: EE::API::Entities::UserPublic)
API::Entities.prepend_entity(::API::Entities::Variable, with: EE::API::Entities::Variable)
API::Entities.prepend_entity(::API::Entities::Todo, with: EE::API::Entities::Todo)
module API
class Todos < Grape::API
include PaginationParams
prepend EE::API::Todos
before { authenticate! }
......
......@@ -5678,12 +5678,6 @@ msgstr ""
msgid "There are problems accessing Git storage: "
msgstr ""
msgid "There was an error adding a todo."
msgstr ""
msgid "There was an error deleting the todo."
msgstr ""
msgid "There was an error loading users activity calendar."
msgstr ""
......
......@@ -5,29 +5,10 @@ describe Projects::TodosController do
let(:project) { create(:project) }
let(:issue) { create(:issue, project: project) }
let(:merge_request) { create(:merge_request, source_project: project) }
let(:parent) { project }
shared_examples 'project todos actions' do
it_behaves_like 'todos actions'
context 'when not authorized for resource' do
before do
project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE)
project.project_feature.update!(merge_requests_access_level: ProjectFeature::PRIVATE)
sign_in(user)
end
it "doesn't create todo" do
expect { post_create }.not_to change { user.todos.count }
expect(response).to have_gitlab_http_status(404)
end
end
end
context 'Issues' do
describe 'POST create' do
def post_create
def go
post :create,
namespace_id: project.namespace,
project_id: project,
......@@ -36,13 +17,66 @@ describe Projects::TodosController do
format: 'html'
end
it_behaves_like 'project todos actions'
context 'when authorized' do
before do
sign_in(user)
project.add_developer(user)
end
it 'creates todo for issue' do
expect do
go
end.to change { user.todos.count }.by(1)
expect(response).to have_gitlab_http_status(200)
end
it 'returns todo path and pending count' do
go
expect(response).to have_gitlab_http_status(200)
expect(json_response['count']).to eq 1
expect(json_response['delete_path']).to match(%r{/dashboard/todos/\d{1}})
end
end
context 'when not authorized for project' do
it 'does not create todo for issue that user has no access to' do
sign_in(user)
expect do
go
end.to change { user.todos.count }.by(0)
expect(response).to have_gitlab_http_status(404)
end
it 'does not create todo for issue when user not logged in' do
expect do
go
end.to change { user.todos.count }.by(0)
expect(response).to have_gitlab_http_status(302)
end
end
context 'when not authorized for issue' do
before do
project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE)
sign_in(user)
end
it "doesn't create todo" do
expect { go }.not_to change { user.todos.count }
expect(response).to have_gitlab_http_status(404)
end
end
end
end
context 'Merge Requests' do
describe 'POST create' do
def post_create
def go
post :create,
namespace_id: project.namespace,
project_id: project,
......@@ -51,7 +85,60 @@ describe Projects::TodosController do
format: 'html'
end
it_behaves_like 'project todos actions'
context 'when authorized' do
before do
sign_in(user)
project.add_developer(user)
end
it 'creates todo for merge request' do
expect do
go
end.to change { user.todos.count }.by(1)
expect(response).to have_gitlab_http_status(200)
end
it 'returns todo path and pending count' do
go
expect(response).to have_gitlab_http_status(200)
expect(json_response['count']).to eq 1
expect(json_response['delete_path']).to match(%r{/dashboard/todos/\d{1}})
end
end
context 'when not authorized for project' do
it 'does not create todo for merge request user has no access to' do
sign_in(user)
expect do
go
end.to change { user.todos.count }.by(0)
expect(response).to have_gitlab_http_status(404)
end
it 'does not create todo for merge request user has no access to' do
expect do
go
end.to change { user.todos.count }.by(0)
expect(response).to have_gitlab_http_status(302)
end
end
context 'when not authorized for merge_request' do
before do
project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
project.project_feature.update!(merge_requests_access_level: ProjectFeature::PRIVATE)
sign_in(user)
end
it "doesn't create todo" do
expect { go }.not_to change { user.todos.count }
expect(response).to have_gitlab_http_status(404)
end
end
end
end
end
FactoryBot.define do
factory :todo do
project
author { project&.creator || user }
user { project&.creator || user }
author { project.creator }
user { project.creator }
target factory: :issue
action { Todo::ASSIGNED }
......
......@@ -4,36 +4,23 @@ describe 'Dashboard > User filters todos', :js do
let(:user_1) { create(:user, username: 'user_1', name: 'user_1') }
let(:user_2) { create(:user, username: 'user_2', name: 'user_2') }
let(:group1) { create(:group) }
let(:group2) { create(:group) }
let(:project_1) { create(:project, name: 'project_1') }
let(:project_2) { create(:project, name: 'project_2') }
let(:project_1) { create(:project, name: 'project_1', namespace: group1) }
let(:project_2) { create(:project, name: 'project_2', namespace: group1) }
let(:project_3) { create(:project, name: 'project_3', namespace: group2) }
let(:issue1) { create(:issue, title: 'issue', project: project_1) }
let(:issue2) { create(:issue, title: 'issue', project: project_3) }
let(:issue) { create(:issue, title: 'issue', project: project_1) }
let!(:merge_request) { create(:merge_request, source_project: project_2, title: 'merge_request') }
before do
create(:todo, user: user_1, author: user_2, project: project_1, target: issue1, action: 1)
create(:todo, user: user_1, author: user_2, project: project_3, target: issue2, action: 1)
create(:todo, user: user_1, author: user_2, project: project_1, target: issue, action: 1)
create(:todo, user: user_1, author: user_1, project: project_2, target: merge_request, action: 2)
project_1.add_developer(user_1)
project_2.add_developer(user_1)
project_3.add_developer(user_1)
sign_in(user_1)
visit dashboard_todos_path
end
it 'displays all todos without a filter' do
expect(page).to have_content issue1.to_reference(full: true)
expect(page).to have_content merge_request.to_reference(full: true)
expect(page).to have_content issue2.to_reference(full: true)
end
it 'filters by project' do
click_button 'Project'
within '.dropdown-menu-project' do
......@@ -47,20 +34,6 @@ describe 'Dashboard > User filters todos', :js do
expect(page).not_to have_content project_2.full_name
end
it 'filters by group' do
click_button 'Group'
within '.dropdown-menu-group' do
fill_in 'Search groups', with: group1.full_name
click_link group1.full_name
end
wait_for_requests
expect(page).to have_content issue1.to_reference(full: true)
expect(page).to have_content merge_request.to_reference(full: true)
expect(page).not_to have_content issue2.to_reference(full: true)
end
context 'Author filter' do
it 'filters by author' do
click_button 'Author'
......@@ -90,7 +63,7 @@ describe 'Dashboard > User filters todos', :js do
it 'shows only authors of existing done todos' do
user_3 = create :user
user_4 = create :user
create(:todo, user: user_1, author: user_3, project: project_1, target: issue1, action: 1, state: :done)
create(:todo, user: user_1, author: user_3, project: project_1, target: issue, action: 1, state: :done)
create(:todo, user: user_1, author: user_4, project: project_2, target: merge_request, action: 2, state: :done)
project_1.add_developer(user_3)
......@@ -119,15 +92,14 @@ describe 'Dashboard > User filters todos', :js do
wait_for_requests
expect(find('.todos-list')).to have_content issue1.to_reference
expect(find('.todos-list')).to have_content issue2.to_reference
expect(find('.todos-list')).to have_content issue.to_reference
expect(find('.todos-list')).not_to have_content merge_request.to_reference
end
describe 'filter by action' do
before do
create(:todo, :build_failed, user: user_1, author: user_2, project: project_1)
create(:todo, :marked, user: user_1, author: user_2, project: project_1, target: issue1)
create(:todo, :marked, user: user_1, author: user_2, project: project_1, target: issue)
end
it 'filters by Assigned' do
......
......@@ -5,76 +5,12 @@ describe TodosFinder do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
let(:issue) { create(:issue, project: project) }
let(:merge_request) { create(:merge_request, source_project: project) }
let(:finder) { described_class }
before do
group.add_developer(user)
end
describe '#execute' do
context 'visibility' do
let(:private_group_access) { create(:group, :private) }
let(:private_group_hidden) { create(:group, :private) }
let(:public_project) { create(:project, :public) }
let(:private_project_hidden) { create(:project) }
let(:public_group) { create(:group) }
let!(:todo1) { create(:todo, user: user, project: project, group: nil) }
let!(:todo2) { create(:todo, user: user, project: public_project, group: nil) }
let!(:todo3) { create(:todo, user: user, project: private_project_hidden, group: nil) }
let!(:todo4) { create(:todo, user: user, project: nil, group: group) }
let!(:todo5) { create(:todo, user: user, project: nil, group: private_group_access) }
let!(:todo6) { create(:todo, user: user, project: nil, group: private_group_hidden) }
let!(:todo7) { create(:todo, user: user, project: nil, group: public_group) }
before do
private_group_access.add_developer(user)
end
it 'returns only todos with a target a user has access to' do
todos = finder.new(user).execute
expect(todos).to match_array([todo1, todo2, todo4, todo5, todo7])
end
end
context 'filtering' do
let!(:todo1) { create(:todo, user: user, project: project, target: issue) }
let!(:todo2) { create(:todo, user: user, group: group, target: merge_request) }
it 'returns correct todos when filtered by a project' do
todos = finder.new(user, { project_id: project.id }).execute
expect(todos).to match_array([todo1])
end
it 'returns correct todos when filtered by a group' do
todos = finder.new(user, { group_id: group.id }).execute
expect(todos).to match_array([todo1, todo2])
end
it 'returns correct todos when filtered by a type' do
todos = finder.new(user, { type: 'Issue' }).execute
expect(todos).to match_array([todo1])
end
context 'with subgroups', :nested_groups do
let(:subgroup) { create(:group, parent: group) }
let!(:todo3) { create(:todo, user: user, group: subgroup, target: issue) }
it 'returns todos from subgroups when filtered by a group' do
todos = finder.new(user, { group_id: group.id }).execute
expect(todos).to match_array([todo1, todo2, todo3])
end
end
end
end
describe '#sort' do
context 'by date' do
let!(:todo1) { create(:todo, user: user, project: project) }
......
......@@ -21,27 +21,6 @@ describe IssuablesHelper do
end
end
describe '#group_dropdown_label' do
let(:group) { create(:group) }
let(:default) { 'default label' }
it 'returns default group label when group_id is nil' do
expect(group_dropdown_label(nil, default)).to eq('default label')
end
it 'returns "any group" when group_id is 0' do
expect(group_dropdown_label('0', default)).to eq('Any group')
end
it 'returns group full path when a group was found for the provided id' do
expect(group_dropdown_label(group.id, default)).to eq(group.full_name)
end
it 'returns default label when a group was not found for the provided id' do
expect(group_dropdown_label(9999, default)).to eq('default label')
end
end
describe '#issuable_labels_tooltip' do
it 'returns label text with no labels' do
expect(issuable_labels_tooltip([])).to eq("Labels")
......
......@@ -23,7 +23,6 @@ describe('EpicShowApp', () => {
mock.onAny().reply(404, null);
const {
epicId,
canUpdate,
canDestroy,
endpoint,
......@@ -44,9 +43,6 @@ describe('EpicShowApp', () => {
participants,
subscribed,
toggleSubscriptionPath,
todoExists,
todoPath,
todoDeletePath,
} = props;
const EpicShowApp = Vue.extend(epicShowApp);
......@@ -80,7 +76,6 @@ describe('EpicShowApp', () => {
const EpicSidebar = Vue.extend(epicSidebar);
sidebarVm = mountComponent(EpicSidebar, {
epicId,
endpoint,
editable: canUpdate,
initialStartDate: startDate,
......@@ -88,15 +83,12 @@ describe('EpicShowApp', () => {
initialLabels: labels,
initialParticipants: participants,
initialSubscribed: subscribed,
initialTodoExists: todoExists,
updatePath: updateEndpoint,
toggleSubscriptionPath,
labelsPath,
labelsWebUrl,
epicsWebUrl,
namespace,
todoPath,
todoDeletePath,
});
setTimeout(done);
......
......@@ -28,12 +28,9 @@ export const mockParticipants = [
];
export const contentProps = {
epicId: 1,
endpoint: '',
toggleSubscriptionPath: gl.TEST_HOST,
updateEndpoint: gl.TEST_HOST,
todoPath: gl.TEST_HOST,
todoDeletePath: gl.TEST_HOST,
canAdmin: true,
canUpdate: true,
canDestroy: true,
......@@ -52,7 +49,6 @@ export const contentProps = {
labels: mockLabels,
participants: mockParticipants,
subscribed: true,
todoExists: false,
};
export const headerProps = {
......
......@@ -13,7 +13,6 @@ describe('epicSidebar', () => {
let originalCookieState;
let EpicSidebar;
const {
epicId,
updateEndpoint,
labelsPath,
labelsWebUrl,
......@@ -22,25 +21,18 @@ describe('epicSidebar', () => {
participants,
subscribed,
toggleSubscriptionPath,
todoExists,
todoPath,
todoDeletePath,
} = props;
const defaultPropsData = {
epicId,
endpoint: gl.TEST_HOST,
initialLabels: labels,
initialParticipants: participants,
initialSubscribed: subscribed,
initialTodoExists: todoExists,
updatePath: updateEndpoint,
toggleSubscriptionPath,
labelsPath,
labelsWebUrl,
epicsWebUrl,
todoPath,
todoDeletePath,
};
beforeEach(() => {
......@@ -99,7 +91,7 @@ describe('epicSidebar', () => {
});
it('should render collapsed grouped date picker', () => {
expect(vm.$el.querySelector('.sidebar-grouped-item .sidebar-collapsed-icon span').innerText.trim()).toEqual('From Jan 1 2017');
expect(vm.$el.querySelector('.sidebar-collapsed-icon span').innerText.trim()).toEqual('From Jan 1 2017');
});
it('should render collapsed labels picker', () => {
......@@ -220,96 +212,6 @@ describe('epicSidebar', () => {
});
});
describe('handleToggleTodo', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
setFixtures('<div class="flash-container"></div>');
});
afterEach(() => {
document.querySelector('.flash-container').remove();
mock.restore();
});
it('calls `addTodo` on service object when `todoExists` prop is `false`', () => {
spyOn(vm.service, 'addTodo').and.callThrough();
vm.store.setTodoExists(false);
expect(vm.savingTodoAction).toBe(false);
vm.handleToggleTodo();
expect(vm.savingTodoAction).toBe(true);
expect(vm.service.addTodo).toHaveBeenCalledWith(epicId);
});
it('calls `addTodo` on service and sets response on store when request is successful', done => {
mock.onPost(gl.TEST_HOST).reply(200, {
delete_path: '/foo/bar',
count: 1,
});
spyOn(vm.service, 'addTodo').and.callThrough();
vm.store.setTodoExists(false);
vm.handleToggleTodo();
setTimeout(() => {
expect(vm.savingTodoAction).toBe(false);
expect(vm.store.todoDeletePath).toBe('/foo/bar');
expect(vm.store.todoExists).toBe(true);
done();
}, 0);
});
it('calls `addTodo` on service and shows Flash error when request is unsuccessful', done => {
mock.onPost(gl.TEST_HOST).reply(500, {});
spyOn(vm.service, 'addTodo').and.callThrough();
vm.store.setTodoExists(false);
vm.handleToggleTodo();
setTimeout(() => {
expect(vm.savingTodoAction).toBe(false);
expect(document.querySelector('.flash-text').innerText.trim()).toBe('There was an error adding a todo.');
done();
}, 0);
});
it('calls `deleteTodo` on service object when `todoExists` prop is `true`', () => {
spyOn(vm.service, 'deleteTodo').and.callThrough();
vm.store.setTodoExists(true);
expect(vm.savingTodoAction).toBe(false);
vm.handleToggleTodo();
expect(vm.savingTodoAction).toBe(true);
expect(vm.service.deleteTodo).toHaveBeenCalledWith(gl.TEST_HOST);
});
it('calls `deleteTodo` on service and sets response on store when request is successful', done => {
mock.onDelete(gl.TEST_HOST).reply(200, {
count: 1,
});
spyOn(vm.service, 'deleteTodo').and.callThrough();
vm.store.setTodoExists(true);
vm.handleToggleTodo();
setTimeout(() => {
expect(vm.savingTodoAction).toBe(false);
expect(vm.store.todoExists).toBe(false);
done();
}, 0);
});
it('calls `deleteTodo` on service and shows Flash error when request is unsuccessful', done => {
mock.onDelete(gl.TEST_HOST).reply(500, {});
spyOn(vm.service, 'deleteTodo').and.callThrough();
vm.store.setTodoExists(true);
vm.handleToggleTodo();
setTimeout(() => {
expect(vm.savingTodoAction).toBe(false);
expect(document.querySelector('.flash-text').innerText.trim()).toBe('There was an error deleting the todo.');
done();
}, 0);
});
});
describe('saveDate error', () => {
let interceptor;
let component;
......
import axios from '~/lib/utils/axios_utils';
import SidebarService from 'ee/epics/sidebar/services/sidebar_service';
describe('Sidebar Service', () => {
let service;
beforeEach(() => {
service = new SidebarService({
endpoint: gl.TEST_HOST,
subscriptionEndpoint: gl.TEST_HOST,
todoPath: gl.TEST_HOST,
});
});
describe('updateStartDate', () => {
it('returns axios instance with PUT for `endpoint` and `start_date` as request body', () => {
spyOn(axios, 'put').and.stub();
const startDate = '2018-06-21';
service.updateStartDate(startDate);
expect(axios.put).toHaveBeenCalledWith(service.endpoint, {
start_date: startDate,
});
});
});
describe('updateEndDate', () => {
it('returns axios instance with PUT for `endpoint` and `end_date` as request body', () => {
spyOn(axios, 'put').and.stub();
const endDate = '2018-06-21';
service.updateEndDate(endDate);
expect(axios.put).toHaveBeenCalledWith(service.endpoint, {
end_date: endDate,
});
});
});
describe('toggleSubscribed', () => {
it('returns axios instance with POST for `subscriptionEndpoint`', () => {
spyOn(axios, 'post').and.stub();
service.toggleSubscribed();
expect(axios.post).toHaveBeenCalled();
});
});
describe('addTodo', () => {
it('returns axios instance with POST for `todoPath` with `issuable_id` and `issuable_type` as request body', () => {
spyOn(axios, 'post').and.stub();
const epicId = 1;
service.addTodo(epicId);
expect(axios.post).toHaveBeenCalledWith(service.todoPath, {
issuable_id: epicId,
issuable_type: 'epic',
});
});
});
describe('deleteTodo', () => {
it('returns axios instance with DELETE for provided `todoDeletePath` param', () => {
spyOn(axios, 'delete').and.stub();
service.deleteTodo('/foo/bar');
expect(axios.delete).toHaveBeenCalledWith('/foo/bar');
});
});
});
......@@ -63,22 +63,4 @@ describe('Sidebar Store', () => {
expect(store.subscribed).toEqual(false);
});
});
describe('setTodoExists', () => {
it('should set store.subscribed value', () => {
const store = new SidebarStore({ todoExists: true });
store.setTodoExists(false);
expect(store.todoExists).toEqual(false);
});
});
describe('setTodoDeletePath', () => {
it('should set store.subscribed value', () => {
const store = new SidebarStore({ todoDeletePath: gl.TEST_HOST });
store.setTodoDeletePath('/foo/bar');
expect(store.todoDeletePath).toEqual('/foo/bar');
});
});
});
import Vue from 'vue';
import SidebarTodos from '~/sidebar/components/todo_toggle/todo.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
const createComponent = ({
issuableId = 1,
issuableType = 'epic',
isTodo,
isActionActive,
collapsed,
}) => {
const Component = Vue.extend(SidebarTodos);
return mountComponent(Component, {
issuableId,
issuableType,
isTodo,
isActionActive,
collapsed,
});
};
describe('SidebarTodo', () => {
let vm;
beforeEach(() => {
vm = createComponent({});
});
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('buttonClasses', () => {
it('returns todo button classes for when `collapsed` prop is `false`', () => {
expect(vm.buttonClasses).toBe('btn btn-default btn-todo issuable-header-btn float-right');
});
it('returns todo button classes for when `collapsed` prop is `true`', done => {
vm.collapsed = true;
Vue.nextTick()
.then(() => {
expect(vm.buttonClasses).toBe('btn-blank btn-todo sidebar-collapsed-icon dont-change-state');
})
.then(done)
.catch(done.fail);
});
});
describe('buttonLabel', () => {
it('returns todo button text for marking todo as done when `isTodo` prop is `true`', () => {
expect(vm.buttonLabel).toBe('Mark todo as done');
});
it('returns todo button text for add todo when `isTodo` prop is `false`', done => {
vm.isTodo = false;
Vue.nextTick()
.then(() => {
expect(vm.buttonLabel).toBe('Add todo');
})
.then(done)
.catch(done.fail);
});
});
describe('collapsedButtonIconClasses', () => {
it('returns collapsed button icon class when `isTodo` prop is `true`', () => {
expect(vm.collapsedButtonIconClasses).toBe('todo-undone');
});
it('returns empty string when `isTodo` prop is `false`', done => {
vm.isTodo = false;
Vue.nextTick()
.then(() => {
expect(vm.collapsedButtonIconClasses).toBe('');
})
.then(done)
.catch(done.fail);
});
});
describe('collapsedButtonIcon', () => {
it('returns button icon name when `isTodo` prop is `true`', () => {
expect(vm.collapsedButtonIcon).toBe('todo-done');
});
it('returns button icon name when `isTodo` prop is `false`', done => {
vm.isTodo = false;
Vue.nextTick()
.then(() => {
expect(vm.collapsedButtonIcon).toBe('todo-add');
})
.then(done)
.catch(done.fail);
});
});
});
describe('methods', () => {
describe('handleButtonClick', () => {
it('emits `toggleTodo` event on component', () => {
spyOn(vm, '$emit');
vm.handleButtonClick();
expect(vm.$emit).toHaveBeenCalledWith('toggleTodo');
});
});
});
describe('template', () => {
it('renders component container element', () => {
const dataAttributes = {
issuableId: '1',
issuableType: 'epic',
originalTitle: 'Mark todo as done',
placement: 'left',
container: 'body',
boundary: 'viewport',
};
expect(vm.$el.nodeName).toBe('BUTTON');
const elDataAttrs = vm.$el.dataset;
Object.keys(elDataAttrs).forEach((attr) => {
expect(elDataAttrs[attr]).toBe(dataAttributes[attr]);
});
});
it('renders button label element when `collapsed` prop is `false`', () => {
const buttonLabelEl = vm.$el.querySelector('span.issuable-todo-inner');
expect(buttonLabelEl).not.toBeNull();
expect(buttonLabelEl.innerText.trim()).toBe('Mark todo as done');
});
it('renders button icon when `collapsed` prop is `true`', done => {
vm.collapsed = true;
Vue.nextTick()
.then(() => {
const buttonIconEl = vm.$el.querySelector('svg');
expect(buttonIconEl).not.toBeNull();
expect(buttonIconEl.querySelector('use').getAttribute('xlink:href')).toContain('todo-done');
})
.then(done)
.catch(done.fail);
});
it('renders loading icon when `isActionActive` prop is true', done => {
vm.isActionActive = true;
Vue.nextTick()
.then(() => {
const loadingEl = vm.$el.querySelector('span.loading-container');
expect(loadingEl).not.toBeNull();
})
.then(done)
.catch(done.fail);
});
});
});
......@@ -7,7 +7,6 @@ describe Todo do
it { is_expected.to belong_to(:author).class_name("User") }
it { is_expected.to belong_to(:note) }
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:group) }
it { is_expected.to belong_to(:target).touch(true) }
it { is_expected.to belong_to(:user) }
end
......
shared_examples 'todos actions' do
context 'when authorized' do
before do
sign_in(user)
parent.add_developer(user)
end
it 'creates todo' do
expect do
post_create
end.to change { user.todos.count }.by(1)
expect(response).to have_gitlab_http_status(200)
end
it 'returns todo path and pending count' do
post_create
expect(response).to have_gitlab_http_status(200)
expect(json_response['count']).to eq 1
expect(json_response['delete_path']).to match(%r{/dashboard/todos/\d{1}})
end
end
context 'when not authorized for project/group' do
it 'does not create todo for resource that user has no access to' do
sign_in(user)
expect do
post_create
end.to change { user.todos.count }.by(0)
expect(response).to have_gitlab_http_status(404)
end
it 'does not create todo when user is not logged in' do
expect do
post_create
end.to change { user.todos.count }.by(0)
expect(response).to have_gitlab_http_status(parent.is_a?(Group) ? 401 : 302)
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