mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
Add favorites
This commit is contained in:
@@ -47,7 +47,8 @@ class Projects::ProjectsFiltersComponent < FiltersComponent
|
||||
Queries::Projects::Filters::CreatedAtFilter,
|
||||
Queries::Projects::Filters::LatestActivityAtFilter,
|
||||
Queries::Projects::Filters::NameAndIdentifierFilter,
|
||||
Queries::Projects::Filters::TypeFilter
|
||||
Queries::Projects::Filters::TypeFilter,
|
||||
Queries::Projects::Filters::FavoredFilter
|
||||
]
|
||||
allowlist << Queries::Filters::Shared::CustomFields::Base if EnterpriseToken.allows_to?(:custom_fields_in_projects_list)
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
#++
|
||||
module Projects
|
||||
class RowComponent < ::RowComponent
|
||||
delegate :favored_projects, to: :table
|
||||
|
||||
def project
|
||||
model.first
|
||||
end
|
||||
@@ -42,6 +44,12 @@ module Projects
|
||||
""
|
||||
end
|
||||
|
||||
def favored
|
||||
if favored_projects.include?(project.id)
|
||||
render(Primer::Beta::Octicon.new(icon: "star-fill", color: :attention, "aria-label": I18n.t(:label_favoured)))
|
||||
end
|
||||
end
|
||||
|
||||
def column_value(column)
|
||||
if custom_field_column?(column)
|
||||
custom_field_column(column)
|
||||
|
||||
@@ -64,7 +64,11 @@ See COPYRIGHT and LICENSE files for more details.
|
||||
<div class="generic-table--sort-header-outer">
|
||||
<div class="generic-table--sort-header">
|
||||
<span>
|
||||
<% if column.attribute == :favored %>
|
||||
<%= render(Primer::Beta::Octicon.new(icon: "star-fill", color: :subtle, "aria-label": I18n.t(:label_favoured))) %>
|
||||
<% else %>
|
||||
<%= column.caption %>
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -153,6 +153,10 @@ module Projects
|
||||
end
|
||||
end
|
||||
|
||||
def favored_projects
|
||||
@favored_projects ||= Favorite.where(user: current_user, favored_type: 'Project').pluck(:favored_id)
|
||||
end
|
||||
|
||||
def sorted_by_lft?
|
||||
query.orders.first&.attribute == :lft
|
||||
end
|
||||
|
||||
@@ -1,34 +1,62 @@
|
||||
class AnnouncementsController < ApplicationController
|
||||
layout "admin"
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) 2012-2024 the OpenProject GmbH
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License version 3.
|
||||
#
|
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
# Copyright (C) 2010-2013 the ChiliProject Team
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
#
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
before_action :require_admin
|
||||
class FavoritesController < ApplicationController
|
||||
before_action :find_favored_by_object
|
||||
before_action :require_login
|
||||
|
||||
def edit
|
||||
@announcement = Announcement.only_one
|
||||
def favorite
|
||||
if @favored.visible?(User.current)
|
||||
set_favored(User.current, true)
|
||||
else
|
||||
render_403
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@announcement = Announcement.only_one
|
||||
@announcement.attributes = announcement_params
|
||||
|
||||
if @announcement.save
|
||||
flash[:notice] = t(:notice_successful_update)
|
||||
end
|
||||
|
||||
redirect_to action: "edit"
|
||||
def unfavorite
|
||||
set_favored(User.current, false)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def default_breadcrumb
|
||||
t(:label_announcement)
|
||||
def find_favored_by_object
|
||||
model_name = params[:object_type]
|
||||
klass = ::OpenProject::Acts::Favorable::Registry.instance(model_name)
|
||||
@favored = klass&.find(params[:object_id])
|
||||
render_404 unless @favored
|
||||
end
|
||||
|
||||
def show_local_breadcrumb
|
||||
true
|
||||
end
|
||||
def set_favored(user, favored)
|
||||
@favored.set_favored(user, favored:)
|
||||
|
||||
def announcement_params
|
||||
params.require(:announcement).permit("text", "show_until", "active")
|
||||
respond_to do |format|
|
||||
format.html { redirect_back(fallback_location: home_url) }
|
||||
format.json { head :no_content }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
include ::OpenProject::Acts::Watchable
|
||||
include ::OpenProject::Acts::Favorable
|
||||
|
||||
self.abstract_class = true
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) 2012-2024 the OpenProject GmbH
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License version 3.
|
||||
#
|
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
# Copyright (C) 2010-2013 the ChiliProject Team
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
#
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
class Favorite < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :favored, polymorphic: true
|
||||
|
||||
validates :user, presence: true
|
||||
validates :favored, presence: true
|
||||
end
|
||||
|
||||
@@ -33,7 +33,6 @@ class Project < ApplicationRecord
|
||||
include Projects::Activity
|
||||
include Projects::Hierarchy
|
||||
include Projects::AncestorsFromRoot
|
||||
|
||||
include ::Scopes::Scoped
|
||||
|
||||
include Projects::ActsAsCustomizablePatches
|
||||
@@ -90,6 +89,8 @@ class Project < ApplicationRecord
|
||||
has_many :project_storages, dependent: :destroy, class_name: 'Storages::ProjectStorage'
|
||||
has_many :storages, through: :project_storages
|
||||
|
||||
acts_as_favorable
|
||||
|
||||
acts_as_customizable # partially overridden via Projects::ActsAsCustomizablePatches in order to support sections and
|
||||
# project-leval activation of custom fields
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ module Queries::Projects
|
||||
filter Filters::ProjectStatusFilter
|
||||
filter Filters::UserActionFilter
|
||||
filter Filters::VisibleFilter
|
||||
filter Filters::FavoredFilter
|
||||
|
||||
order Orders::DefaultOrder
|
||||
order Orders::LatestActivityAtOrder
|
||||
@@ -61,5 +62,6 @@ module Queries::Projects
|
||||
select Selects::LatestActivityAt
|
||||
select Selects::RequiredDiskSpace
|
||||
select Selects::Status
|
||||
select Selects::Favored
|
||||
end
|
||||
end
|
||||
|
||||
@@ -100,7 +100,7 @@ class Queries::Projects::Factory
|
||||
def list_with(name)
|
||||
Queries::Projects::ProjectQuery.new(name: I18n.t(name)) do |query|
|
||||
query.order("lft" => "asc")
|
||||
query.select(*(["name"] + Setting.enabled_projects_columns).uniq, add_not_existing: false)
|
||||
query.select(*(["favored", "name"] + Setting.enabled_projects_columns).uniq, add_not_existing: false)
|
||||
|
||||
yield query
|
||||
end
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) 2012-2024 the OpenProject GmbH
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License version 3.
|
||||
#
|
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
# Copyright (C) 2010-2013 the ChiliProject Team
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
#
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
class Queries::Projects::Filters::FavoredFilter < Queries::Projects::Filters::ProjectFilter
|
||||
include Queries::Filters::Shared::BooleanFilter
|
||||
|
||||
def self.key
|
||||
:favored
|
||||
end
|
||||
|
||||
def available?
|
||||
User.current.logged?
|
||||
end
|
||||
|
||||
def scope
|
||||
if values.first == OpenProject::Database::DB_VALUE_TRUE
|
||||
super.where(id: favored_project_ids)
|
||||
else
|
||||
super.where.not(id: favored_project_ids)
|
||||
end
|
||||
end
|
||||
|
||||
# Handled by scope
|
||||
def where
|
||||
"1=1"
|
||||
end
|
||||
|
||||
def favored_project_ids
|
||||
Favorite
|
||||
.where(favored_type: "Project", user_id: User.current.id)
|
||||
.select(:favored_id)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
# -- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) 2010-2024 the OpenProject GmbH
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License version 3.
|
||||
#
|
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
# Copyright (C) 2010-2013 the ChiliProject Team
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
#
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
# ++
|
||||
|
||||
class Queries::Projects::Selects::Favored < Queries::Selects::Base
|
||||
def self.key
|
||||
:favored
|
||||
end
|
||||
|
||||
def self.available?
|
||||
true
|
||||
end
|
||||
|
||||
def caption
|
||||
I18n.t(:label_favoured)
|
||||
end
|
||||
end
|
||||
@@ -92,6 +92,6 @@ class Queries::Projects::ProjectQueries::SetAttributesService < BaseServices::Se
|
||||
end
|
||||
|
||||
def default_columns
|
||||
(["name"] + Setting.enabled_projects_columns).uniq
|
||||
(["favored", "name"] + Setting.enabled_projects_columns).uniq
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Be sure to restart your server when you modify this file.
|
||||
|
||||
# For development and non-eager load mode, we need to register the acts_as_favorable models manually
|
||||
# as no eager loading takes place
|
||||
Rails.application.config.after_initialize do
|
||||
OpenProject::Acts::Favorable::Registry
|
||||
.add(Project)
|
||||
end
|
||||
@@ -1,10 +1,8 @@
|
||||
# Be sure to restart your server when you modify this file.
|
||||
|
||||
# In development mode, we need to register the acts_as_watchable models manually
|
||||
# In development and non-eager loaded mode, we need to register the acts_as_watchable models manually
|
||||
# as no eager loading takes place
|
||||
Rails.application.config.after_initialize do
|
||||
if Rails.env.development?
|
||||
OpenProject::Acts::Watchable::Registry
|
||||
.add(WorkPackage, Message, Forum, News, Meeting, Wiki, WikiPage)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -38,3 +38,4 @@ require_relative "../../lib_static/open_project/feature_decisions"
|
||||
# initializer 'the_engine.feature_decisions' do
|
||||
# OpenProject::FeatureDecisions.add :some_flag
|
||||
# end
|
||||
OpenProject::FeatureDecisions.add :favorite_projects
|
||||
|
||||
@@ -1346,6 +1346,7 @@ Project attributes and sections are defined in the <a href=%{admin_settings_url}
|
||||
button_edit: "Edit"
|
||||
button_edit_associated_wikipage: "Edit associated Wiki page: %{page_title}"
|
||||
button_expand_all: "Expand all"
|
||||
button_favorite: "Mark as favorite"
|
||||
button_filter: "Filter"
|
||||
button_generate: "Generate"
|
||||
button_list: "List"
|
||||
@@ -1373,6 +1374,7 @@ Project attributes and sections are defined in the <a href=%{admin_settings_url}
|
||||
button_unarchive: "Unarchive"
|
||||
button_uncheck_all: "Uncheck all"
|
||||
button_unlock: "Unlock"
|
||||
button_unfavorite: "Remove from favorites"
|
||||
button_unwatch: "Unwatch"
|
||||
button_update: "Update"
|
||||
button_upgrade: "Upgrade"
|
||||
|
||||
+6
-1
@@ -167,12 +167,17 @@ Rails.application.routes.draw do
|
||||
resource :wiki_menu_item, only: %i[edit update]
|
||||
end
|
||||
|
||||
# generic route for adding/removing watchers and favorites.
|
||||
scope ":object_type/:object_id", constraints: OpenProject::Acts::Watchable::Routes do
|
||||
post "/watch" => "watchers#watch"
|
||||
delete "/unwatch" => "watchers#unwatch"
|
||||
end
|
||||
|
||||
# generic route for adding/removing watchers and favorites.
|
||||
scope ":object_type/:object_id", constraints: OpenProject::Acts::Favorable::Routes do
|
||||
post "/favorite" => "favorites#favorite"
|
||||
delete "/favorite" => "favorites#unfavorite"
|
||||
end
|
||||
|
||||
namespace :projects do
|
||||
resource :menu, only: %i[show]
|
||||
resources :queries, only: %i[new create destroy]
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
class CreateFavorite < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :favorites do |t|
|
||||
t.references :user, null: false, foreign_key: true, index: true
|
||||
t.references :favored, null: false, polymorphic: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :favorites, %i[favored_type favored_id]
|
||||
add_index :favorites, %i[user_id favored_type favored_id], unique: true
|
||||
end
|
||||
end
|
||||
@@ -10,6 +10,8 @@ import { GRID_PROVIDERS } from 'core-app/shared/components/grids/grid/grid.compo
|
||||
providers: GRID_PROVIDERS,
|
||||
})
|
||||
export class OverviewComponent extends GridPageComponent {
|
||||
showToolbar = false;
|
||||
|
||||
protected i18nNamespace():string {
|
||||
return 'overviews';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<div class="op-grid-page">
|
||||
<div class="toolbar op-grid-page--toolbar-container">
|
||||
<div *ngIf="showToolbar"
|
||||
class="toolbar op-grid-page--toolbar-container">
|
||||
<div class="op-grid-page--title-container title-container">
|
||||
<h2 [textContent]="text.title"></h2>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import {
|
||||
ChangeDetectorRef, Directive, OnDestroy, OnInit, Renderer2,
|
||||
} from '@angular/core';
|
||||
import { ChangeDetectorRef, Directive, OnDestroy, OnInit, Renderer2 } from '@angular/core';
|
||||
import { I18nService } from 'core-app/core/i18n/i18n.service';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { GridInitializationService } from 'core-app/shared/components/grids/grid/initialization.service';
|
||||
@@ -18,6 +16,8 @@ export abstract class GridPageComponent implements OnInit, OnDestroy {
|
||||
html_title: this.i18n.t(`js.${this.i18nNamespace()}.label`),
|
||||
};
|
||||
|
||||
public showToolbar = true;
|
||||
|
||||
constructor(
|
||||
readonly gridInitialization:GridInitializationService,
|
||||
// not used in the base class but will be used throughout the subclasses
|
||||
|
||||
@@ -34,59 +34,31 @@ module OpenProject
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
# Marks an ActiveRecord::Model as watchable
|
||||
# A watchable model has association with users (watchers) who wish to
|
||||
# be informed of changes on it.
|
||||
# Marks an ActiveRecord::Model as favorable
|
||||
# A favorable model has association with users (watchers) that marked it as favorite.
|
||||
#
|
||||
# This also creates the routes necessary for watching/unwatching by
|
||||
# This also creates the routes necessary for favoring/unfavoring by
|
||||
# adding the model's name to routes. This e.g leads to the following
|
||||
# routes when marking issues as watchable:
|
||||
# POST: issues/1/watch
|
||||
# DELETE: issues/1/unwatch
|
||||
# GET/POST: issues/1/watchers/new
|
||||
# DELETE: issues/1/watchers/1
|
||||
# POST: projects/identifier/favorite
|
||||
# DELETE: projects/identifier/favorite
|
||||
#
|
||||
# params:
|
||||
# options:
|
||||
# permission: overrides the permission used to determine whether a user
|
||||
# is allowed to watch
|
||||
|
||||
def acts_as_watchable(options = {})
|
||||
return if included_modules.include?(::OpenProject::Acts::Watchable::InstanceMethods)
|
||||
|
||||
acts_as_watchable_enforce_project_association
|
||||
# acts_as_favorable expects that the including module defines a +visible?(user)+ method,
|
||||
# as it's used to identify whether a user can actually favorite the object.
|
||||
def acts_as_favorable
|
||||
return if included_modules.include?(::OpenProject::Acts::Favorable::InstanceMethods)
|
||||
|
||||
class_eval do
|
||||
has_many :watchers, as: :watchable, dependent: :delete_all, validate: false
|
||||
has_many :watcher_users, through: :watchers, source: :user, validate: false
|
||||
has_many :favorites, as: :favored, dependent: :delete_all, validate: false
|
||||
has_many :favoring_users, through: :favorites, source: :user, validate: false
|
||||
|
||||
scope :watched_by, ->(user_id) {
|
||||
includes(:watchers)
|
||||
.where(watchers: { user_id: })
|
||||
scope :favored_by, ->(user_id) {
|
||||
includes(:favorites)
|
||||
.where(favorites: { user_id: })
|
||||
}
|
||||
|
||||
class_attribute :acts_as_watchable_options
|
||||
|
||||
self.acts_as_watchable_options = options
|
||||
end
|
||||
|
||||
send :prepend, OpenProject::OpenProject::Acts::Watchable::InstanceMethods
|
||||
end
|
||||
|
||||
def acts_as_watchable_enforce_project_association
|
||||
unless reflect_on_association(:project)
|
||||
message = <<-MESSAGE
|
||||
|
||||
The #{self} model does not have an association to the Project model.
|
||||
|
||||
acts_as_watchable requires the including model to have such an association.
|
||||
|
||||
If no direct association exists, consider adding a
|
||||
has_one :project, through: ...
|
||||
association.
|
||||
MESSAGE
|
||||
raise message
|
||||
end
|
||||
send :prepend, ::OpenProject::Acts::Favorable::InstanceMethods
|
||||
end
|
||||
end
|
||||
|
||||
@@ -95,75 +67,22 @@ module OpenProject
|
||||
base.extend ClassMethods
|
||||
end
|
||||
|
||||
def possible_watcher?(user)
|
||||
user.allowed_based_on_permission_context?(self.class.acts_as_watchable_permission,
|
||||
project:,
|
||||
entity: self)
|
||||
def add_favoring_user(user)
|
||||
return if favorites.exists?(user_id: user.id)
|
||||
|
||||
favorites << Favorite.new(user:, favored: self)
|
||||
end
|
||||
|
||||
# Returns all users that could potentially be watchers.
|
||||
# This includes those already added as watchers.
|
||||
#
|
||||
# Admins are excluded at least for non public projects
|
||||
# because while they have the right to be added as watchers having
|
||||
# them pop up in every project would be weird.
|
||||
def possible_watcher_users
|
||||
active_scope = Principal.not_locked.user
|
||||
|
||||
allowed_scope = if project.public?
|
||||
User.allowed(self.class.acts_as_watchable_permission, project)
|
||||
else
|
||||
User.allowed_members_on_work_package(self.class.acts_as_watchable_permission, self)
|
||||
end
|
||||
|
||||
active_scope.where(id: allowed_scope)
|
||||
def remove_favoring_user(user)
|
||||
favorites.where(user:).delete_all
|
||||
end
|
||||
|
||||
# Returns an array of users that are proposed as watchers
|
||||
def addable_watcher_users
|
||||
possible_watcher_users.where.not(id: watcher_users.pluck(:id))
|
||||
def set_favored(user, favored: true)
|
||||
favored ? add_favoring_user(user) : remove_favoring_user(user)
|
||||
end
|
||||
|
||||
# Adds user as a watcher
|
||||
def add_watcher(user)
|
||||
watchers << Watcher.new(user:, watchable: self) unless watchers.map(&:user_id).include?(user.id)
|
||||
end
|
||||
|
||||
# Removes user from the watchers list
|
||||
def remove_watcher(user)
|
||||
return nil unless user&.is_a?(User)
|
||||
|
||||
watchers_to_delete = watchers.find_all { |watcher| watcher.user == user }
|
||||
watchers_to_delete.each(&:delete)
|
||||
watchers.reload
|
||||
watchers_to_delete.count
|
||||
end
|
||||
|
||||
# Adds/removes watcher
|
||||
def set_watcher(user, watching = true)
|
||||
watching ? add_watcher(user) : remove_watcher(user)
|
||||
end
|
||||
|
||||
# Overrides watcher_user_ids= to make user_ids uniq
|
||||
def watcher_user_ids=(user_ids)
|
||||
if user_ids.is_a?(Array)
|
||||
user_ids = user_ids.uniq
|
||||
end
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
# Returns true if object is watched by +user+
|
||||
def watched_by?(user)
|
||||
user.present? &&
|
||||
((watchers.loaded? && watchers.map(&:user_id).any? { |uid| uid == user.id }) ||
|
||||
watcher_user_ids.any? { |uid| uid == user.id })
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
def acts_as_watchable_permission
|
||||
acts_as_watchable_options[:permission] || :"view_#{name.underscore.pluralize}"
|
||||
end
|
||||
def favored_by?(user)
|
||||
favorites.exists?(user:)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) 2012-2024 the OpenProject GmbH
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License version 3.
|
||||
#
|
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
# Copyright (C) 2010-2013 the ChiliProject Team
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
#
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
module OpenProject
|
||||
module Acts
|
||||
module Favorable
|
||||
module Registry
|
||||
def self.models
|
||||
@models ||= Set.new
|
||||
end
|
||||
|
||||
def self.exists?(model)
|
||||
models.include?(model)
|
||||
end
|
||||
|
||||
def self.instance(model_name)
|
||||
models.detect { |cls| cls.name == model_name.singularize.camelize }
|
||||
end
|
||||
|
||||
def self.add(*models)
|
||||
models.each do |model|
|
||||
unless model.ancestors.include?(::OpenProject::Acts::Watchable)
|
||||
raise ArgumentError.new("Model #{model} does not include acts_as_watchable")
|
||||
end
|
||||
|
||||
self.models << model
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) 2012-2024 the OpenProject GmbH
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License version 3.
|
||||
#
|
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
# Copyright (C) 2010-2013 the ChiliProject Team
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
#
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
module OpenProject
|
||||
module Acts
|
||||
module Favorable
|
||||
module Routes
|
||||
def self.matches?(request)
|
||||
params = request.path_parameters
|
||||
|
||||
Registry.instance(params[:object_type]) &&
|
||||
/\d+/.match(params[:object_id])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -39,7 +39,7 @@ module OpenProject
|
||||
end
|
||||
|
||||
def self.instance(model_name)
|
||||
models.detect { |cls| cls.name == model_name }
|
||||
models.detect { |cls| cls.name == model_name.singularize.camelize }
|
||||
end
|
||||
|
||||
def self.add(*models)
|
||||
|
||||
@@ -34,7 +34,7 @@ module OpenProject
|
||||
def self.matches?(request)
|
||||
params = request.path_parameters
|
||||
|
||||
Registry.exists?(params[:object_type]) &&
|
||||
Registry.instance(params[:object_type]) &&
|
||||
/\d+/.match(params[:object_id])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,6 +8,10 @@ module ::Overviews
|
||||
|
||||
menu_item :overview
|
||||
|
||||
def show
|
||||
render
|
||||
end
|
||||
|
||||
def project_custom_fields_sidebar
|
||||
render :project_custom_fields_sidebar, layout: false
|
||||
end
|
||||
|
||||
@@ -1,3 +1,41 @@
|
||||
<% content_for :header_tags do %>
|
||||
<meta name="sidebar_enabled" data-enabled="<%= @sidebar_enabled %>">
|
||||
<% end -%>
|
||||
|
||||
<%=
|
||||
render(Primer::OpenProject::PageHeader.new) do |header|
|
||||
header.with_title(variant: :medium) { t("overviews.label") }
|
||||
header.with_breadcrumbs(
|
||||
[
|
||||
{ href: project_path(@project), text: @project.name },
|
||||
t("overviews.label")
|
||||
]
|
||||
)
|
||||
|
||||
header.with_actions do
|
||||
if OpenProject::FeatureDecisions.favorite_projects_active?
|
||||
favored = @project.favored_by?(User.current)
|
||||
primer_form_with(
|
||||
url: favorite_path(object_id: @project.id, object_type: @project.model_name.route_key),
|
||||
method: favored ? :delete : :post
|
||||
) do |_form|
|
||||
render(
|
||||
Primer::Beta::IconButton.new(
|
||||
icon: favored ? "star-fill" : "star",
|
||||
size: :medium,
|
||||
color: favored ? :attention : :default,
|
||||
type: :submit,
|
||||
aria: { label: favored ? I18n.t(:button_unfavorite) : I18n.t(:button_favorite) },
|
||||
test_selector: 'project-favorite-button'
|
||||
)
|
||||
) do |button|
|
||||
button.with_leading_visual_icon(icon: :trash)
|
||||
I18n.t('button_delete')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
%>
|
||||
|
||||
<openproject-base></openproject-base>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) 2012-2024 the OpenProject GmbH
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License version 3.
|
||||
#
|
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
# Copyright (C) 2010-2013 the ChiliProject Team
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
#
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
require "spec_helper"
|
||||
require_relative "../../support/pages/my/page"
|
||||
|
||||
|
||||
RSpec.describe "Project favorites",
|
||||
:js,
|
||||
with_flag: :favorite_projects do
|
||||
shared_let(:project) { create(:project, name: 'My favorite!', enabled_module_names: []) }
|
||||
let(:permissions) { %i(edit_project select_project_modules view_work_packages) }
|
||||
let(:projects_page) { Pages::Projects::Index.new }
|
||||
|
||||
current_user do
|
||||
create(:user, member_with_permissions: { project => permissions })
|
||||
end
|
||||
|
||||
it "allows favoriting and unfavoriting projects" do
|
||||
visit project_path(project)
|
||||
expect(page).to have_selector :button, accessible_name: "Mark as favorite"
|
||||
|
||||
click_link_or_button(accessible_name: "Mark as favorite")
|
||||
|
||||
expect(page).to have_selector :button, accessible_name: "Remove from favorite"
|
||||
|
||||
project.reload
|
||||
expect(project).to be_favored_by(current_user)
|
||||
|
||||
projects_page.visit!
|
||||
projects_page.open_filters
|
||||
projects_page.filter_by_favored "yes"
|
||||
|
||||
expect(page).to have_text 'My favorite!'
|
||||
|
||||
projects_page.visit!
|
||||
projects_page.open_filters
|
||||
projects_page.filter_by_favored "no"
|
||||
|
||||
expect(page).to have_no_text 'My favorite!'
|
||||
end
|
||||
end
|
||||
@@ -71,6 +71,9 @@ RSpec.describe Queries::Projects::Factory, with_settings: { enabled_projects_col
|
||||
|
||||
let(:id) { nil }
|
||||
let(:params) { {} }
|
||||
let(:default_selects) do
|
||||
%i[favored] + Setting.enabled_projects_columns.map(&:to_sym)
|
||||
end
|
||||
|
||||
current_user { build_stubbed(:user) }
|
||||
|
||||
@@ -100,7 +103,7 @@ RSpec.describe Queries::Projects::Factory, with_settings: { enabled_projects_col
|
||||
|
||||
it "has the enabled_project_columns columns as selects" do
|
||||
expect(find.selects.map(&:attribute))
|
||||
.to eq(Setting.enabled_projects_columns.map(&:to_sym))
|
||||
.to eq(default_selects)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -115,7 +118,7 @@ RSpec.describe Queries::Projects::Factory, with_settings: { enabled_projects_col
|
||||
|
||||
it "has the enabled_project_columns columns as selects" do
|
||||
expect(find.selects.map(&:attribute))
|
||||
.to eq(Setting.enabled_projects_columns.map(&:to_sym))
|
||||
.to eq(default_selects)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -144,7 +147,7 @@ RSpec.describe Queries::Projects::Factory, with_settings: { enabled_projects_col
|
||||
|
||||
it "has the enabled_project_columns columns as selects" do
|
||||
expect(find.selects.map(&:attribute))
|
||||
.to eq(Setting.enabled_projects_columns.map(&:to_sym))
|
||||
.to eq(default_selects)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -173,7 +176,7 @@ RSpec.describe Queries::Projects::Factory, with_settings: { enabled_projects_col
|
||||
|
||||
it "has the enabled_project_columns columns as selects" do
|
||||
expect(find.selects.map(&:attribute))
|
||||
.to eq(Setting.enabled_projects_columns.map(&:to_sym))
|
||||
.to eq(default_selects)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -202,7 +205,7 @@ RSpec.describe Queries::Projects::Factory, with_settings: { enabled_projects_col
|
||||
|
||||
it "has the enabled_project_columns columns as selects" do
|
||||
expect(find.selects.map(&:attribute))
|
||||
.to eq(Setting.enabled_projects_columns.map(&:to_sym))
|
||||
.to eq(default_selects)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -231,7 +234,7 @@ RSpec.describe Queries::Projects::Factory, with_settings: { enabled_projects_col
|
||||
|
||||
it "has the enabled_project_columns columns as selects" do
|
||||
expect(find.selects.map(&:attribute))
|
||||
.to eq(Setting.enabled_projects_columns.map(&:to_sym))
|
||||
.to eq(default_selects)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -260,7 +263,7 @@ RSpec.describe Queries::Projects::Factory, with_settings: { enabled_projects_col
|
||||
|
||||
it "has the enabled_project_columns columns as selects" do
|
||||
expect(find.selects.map(&:attribute))
|
||||
.to eq(Setting.enabled_projects_columns.map(&:to_sym))
|
||||
.to eq(default_selects)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -289,7 +292,7 @@ RSpec.describe Queries::Projects::Factory, with_settings: { enabled_projects_col
|
||||
|
||||
it "has the enabled_project_columns columns as selects" do
|
||||
expect(find.selects.map(&:attribute))
|
||||
.to eq(Setting.enabled_projects_columns.map(&:to_sym))
|
||||
.to eq(default_selects)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -407,7 +410,7 @@ RSpec.describe Queries::Projects::Factory, with_settings: { enabled_projects_col
|
||||
|
||||
it "has the enabled_project_columns columns as selects" do
|
||||
expect(find.selects.map(&:attribute))
|
||||
.to eq(Setting.enabled_projects_columns.map(&:to_sym))
|
||||
.to eq(default_selects)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -452,7 +455,7 @@ RSpec.describe Queries::Projects::Factory, with_settings: { enabled_projects_col
|
||||
|
||||
it "has the enabled_project_columns columns as selects" do
|
||||
expect(find.selects.map(&:attribute))
|
||||
.to eq(Setting.enabled_projects_columns.map(&:to_sym))
|
||||
.to eq(default_selects)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -657,7 +660,7 @@ RSpec.describe Queries::Projects::Factory, with_settings: { enabled_projects_col
|
||||
|
||||
it "has only the available fields (non admin only and only existing cf)" do
|
||||
expect(find.selects.map(&:attribute))
|
||||
.to eq(%i[name cf_1]) # rubocop:disable Naming/VariableNumber
|
||||
.to eq(%i[favored name cf_1]) # rubocop:disable Naming/VariableNumber
|
||||
end
|
||||
end
|
||||
|
||||
@@ -725,7 +728,7 @@ RSpec.describe Queries::Projects::Factory, with_settings: { enabled_projects_col
|
||||
|
||||
it "has the enabled_project_columns columns as selects" do
|
||||
expect(find.selects.map(&:attribute))
|
||||
.to eq(Setting.enabled_projects_columns.map(&:to_sym))
|
||||
.to eq(default_selects)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -754,7 +757,7 @@ RSpec.describe Queries::Projects::Factory, with_settings: { enabled_projects_col
|
||||
|
||||
it "has the enabled_project_columns columns as selects" do
|
||||
expect(find.selects.map(&:attribute))
|
||||
.to eq(Setting.enabled_projects_columns.map(&:to_sym))
|
||||
.to eq(default_selects)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -783,7 +786,7 @@ RSpec.describe Queries::Projects::Factory, with_settings: { enabled_projects_col
|
||||
|
||||
it "has the enabled_project_columns columns as selects" do
|
||||
expect(find.selects.map(&:attribute))
|
||||
.to eq(Setting.enabled_projects_columns.map(&:to_sym))
|
||||
.to eq(default_selects)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -812,7 +815,7 @@ RSpec.describe Queries::Projects::Factory, with_settings: { enabled_projects_col
|
||||
|
||||
it "has the enabled_project_columns columns as selects" do
|
||||
expect(find.selects.map(&:attribute))
|
||||
.to eq(Setting.enabled_projects_columns.map(&:to_sym))
|
||||
.to eq(default_selects)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -841,7 +844,7 @@ RSpec.describe Queries::Projects::Factory, with_settings: { enabled_projects_col
|
||||
|
||||
it "has the enabled_project_columns columns as selects" do
|
||||
expect(find.selects.map(&:attribute))
|
||||
.to eq(Setting.enabled_projects_columns.map(&:to_sym))
|
||||
.to eq(default_selects)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -870,7 +873,7 @@ RSpec.describe Queries::Projects::Factory, with_settings: { enabled_projects_col
|
||||
|
||||
it "has the enabled_project_columns columns as selects" do
|
||||
expect(find.selects.map(&:attribute))
|
||||
.to eq(Setting.enabled_projects_columns.map(&:to_sym))
|
||||
.to eq(default_selects)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -108,6 +108,7 @@ RSpec.describe Queries::Projects::ProjectQuery do
|
||||
it "lists registered selects" do
|
||||
expect(instance.available_selects.map(&:attribute))
|
||||
.to contain_exactly(:name,
|
||||
:favored,
|
||||
:public,
|
||||
:description,
|
||||
:hierarchy,
|
||||
@@ -122,6 +123,7 @@ RSpec.describe Queries::Projects::ProjectQuery do
|
||||
expect(instance.available_selects.map(&:attribute))
|
||||
.to contain_exactly(:name,
|
||||
:public,
|
||||
:favored,
|
||||
:description,
|
||||
:hierarchy,
|
||||
:project_status,
|
||||
@@ -139,6 +141,7 @@ RSpec.describe Queries::Projects::ProjectQuery do
|
||||
expect(instance.available_selects.map(&:attribute))
|
||||
.to contain_exactly(:name,
|
||||
:public,
|
||||
:favored,
|
||||
:description,
|
||||
:hierarchy,
|
||||
:project_status,
|
||||
|
||||
@@ -182,7 +182,7 @@ RSpec.describe Queries::Projects::ProjectQueries::SetAttributesService, type: :m
|
||||
subject
|
||||
|
||||
expect(model_instance.selects.map(&:attribute))
|
||||
.to eql Setting.enabled_projects_columns.map(&:to_sym)
|
||||
.to eql %i[favored] + Setting.enabled_projects_columns.map(&:to_sym)
|
||||
end
|
||||
|
||||
it "assigns default selects excluding those for admin and ee if not allowed",
|
||||
@@ -190,7 +190,7 @@ RSpec.describe Queries::Projects::ProjectQueries::SetAttributesService, type: :m
|
||||
subject
|
||||
|
||||
expect(model_instance.selects.map(&:attribute))
|
||||
.to eql [:name]
|
||||
.to eql [:favored, :name]
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -177,6 +177,15 @@ module Pages
|
||||
click_button "Apply"
|
||||
end
|
||||
|
||||
def filter_by_favored(value)
|
||||
set_filter("favored",
|
||||
"Favored",
|
||||
"is",
|
||||
[value])
|
||||
|
||||
click_button "Apply"
|
||||
end
|
||||
|
||||
def filter_by_membership(value)
|
||||
set_filter("member_of",
|
||||
"I am member",
|
||||
@@ -376,7 +385,7 @@ module Pages
|
||||
private
|
||||
|
||||
def boolean_filter?(filter)
|
||||
%w[active member_of public templated].include?(filter.to_s)
|
||||
%w[active member_of favored public templated].include?(filter.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -42,6 +42,12 @@ RSpec.configure do |config|
|
||||
config.include WithFlagMixin
|
||||
|
||||
config.before :example, :with_flag do |example|
|
||||
with_flags(example.metadata[:with_flag])
|
||||
value = example.metadata[:with_flag]
|
||||
case value
|
||||
when Symbol
|
||||
with_flags(value => true)
|
||||
else
|
||||
with_flags(value)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user