From e96d2e65062aa96866d687f2738fe66c06ecd458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 4 Apr 2024 11:30:44 +0200 Subject: [PATCH] Add favorites --- .../projects/projects_filters_component.rb | 3 +- app/components/projects/row_component.rb | 8 ++ .../projects/table_component.html.erb | 4 + app/components/projects/table_component.rb | 4 + app/controllers/favorites_controller.rb | 70 +++++++--- app/models/application_record.rb | 2 + app/models/favorite.rb | 35 +++++ app/models/project.rb | 3 +- app/models/queries/projects.rb | 2 + app/models/queries/projects/factory.rb | 2 +- .../projects/filters/favored_filter.rb | 58 ++++++++ .../queries/projects/selects/favored.rb | 41 ++++++ .../project_queries/set_attributes_service.rb | 2 +- config/initializers/acts_as_favorable.rb | 8 ++ config/initializers/acts_as_watchable.rb | 4 +- config/initializers/feature_decisions.rb | 1 + config/locales/en.yml | 2 + config/routes.rb | 7 +- db/migrate/20240404074025_create_favorite.rb | 13 ++ .../features/overview/overview.component.ts | 2 + .../grids/grid/page/grid-page.component.html | 3 +- .../grids/grid/page/grid-page.component.ts | 6 +- lib_static/open_project/acts/favorable.rb | 131 ++++-------------- .../open_project/acts/favorable/registry.rb | 57 ++++++++ .../open_project/acts/favorable/routes.rb | 42 ++++++ .../open_project/acts/watchable/registry.rb | 2 +- .../open_project/acts/watchable/routes.rb | 2 +- .../overviews/overviews_controller.rb | 4 + .../views/overviews/overviews/show.html.erb | 38 +++++ spec/features/projects/favorite_spec.rb | 67 +++++++++ spec/models/queries/projects/factory_spec.rb | 37 ++--- .../queries/projects/project_query_spec.rb | 3 + .../set_attributes_service_spec.rb | 4 +- spec/support/pages/projects/index.rb | 11 +- spec/support/shared/with_flag.rb | 8 +- 35 files changed, 524 insertions(+), 162 deletions(-) create mode 100644 app/models/queries/projects/filters/favored_filter.rb create mode 100644 app/models/queries/projects/selects/favored.rb create mode 100644 config/initializers/acts_as_favorable.rb create mode 100644 db/migrate/20240404074025_create_favorite.rb create mode 100644 lib_static/open_project/acts/favorable/registry.rb create mode 100644 lib_static/open_project/acts/favorable/routes.rb create mode 100644 spec/features/projects/favorite_spec.rb diff --git a/app/components/projects/projects_filters_component.rb b/app/components/projects/projects_filters_component.rb index 6ffc4af774a..450e2545aa7 100644 --- a/app/components/projects/projects_filters_component.rb +++ b/app/components/projects/projects_filters_component.rb @@ -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) diff --git a/app/components/projects/row_component.rb b/app/components/projects/row_component.rb index d395e3111a3..641dbba5773 100644 --- a/app/components/projects/row_component.rb +++ b/app/components/projects/row_component.rb @@ -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) diff --git a/app/components/projects/table_component.html.erb b/app/components/projects/table_component.html.erb index 728f841b1a7..0948e687bba 100644 --- a/app/components/projects/table_component.html.erb +++ b/app/components/projects/table_component.html.erb @@ -64,7 +64,11 @@ See COPYRIGHT and LICENSE files for more details.
+ <% if column.attribute == :favored %> + <%= render(Primer::Beta::Octicon.new(icon: "star-fill", color: :subtle, "aria-label": I18n.t(:label_favoured))) %> + <% else %> <%= column.caption %> + <% end %>
diff --git a/app/components/projects/table_component.rb b/app/components/projects/table_component.rb index e065662df8c..151a344e759 100644 --- a/app/components/projects/table_component.rb +++ b/app/components/projects/table_component.rb @@ -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 diff --git a/app/controllers/favorites_controller.rb b/app/controllers/favorites_controller.rb index 59591fb70d5..53d8df87198 100644 --- a/app/controllers/favorites_controller.rb +++ b/app/controllers/favorites_controller.rb @@ -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 diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 5e061f47287..5015d2199eb 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -1,5 +1,7 @@ + class ApplicationRecord < ActiveRecord::Base include ::OpenProject::Acts::Watchable + include ::OpenProject::Acts::Favorable self.abstract_class = true diff --git a/app/models/favorite.rb b/app/models/favorite.rb index e69de29bb2d..99907152df7 100644 --- a/app/models/favorite.rb +++ b/app/models/favorite.rb @@ -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 diff --git a/app/models/project.rb b/app/models/project.rb index d92a7c22307..1f22f408ada 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -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 diff --git a/app/models/queries/projects.rb b/app/models/queries/projects.rb index 1e25ddb19eb..0872e520d81 100644 --- a/app/models/queries/projects.rb +++ b/app/models/queries/projects.rb @@ -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 diff --git a/app/models/queries/projects/factory.rb b/app/models/queries/projects/factory.rb index a96e4ce64a9..3f0a7279edf 100644 --- a/app/models/queries/projects/factory.rb +++ b/app/models/queries/projects/factory.rb @@ -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 diff --git a/app/models/queries/projects/filters/favored_filter.rb b/app/models/queries/projects/filters/favored_filter.rb new file mode 100644 index 00000000000..8e71a375848 --- /dev/null +++ b/app/models/queries/projects/filters/favored_filter.rb @@ -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 diff --git a/app/models/queries/projects/selects/favored.rb b/app/models/queries/projects/selects/favored.rb new file mode 100644 index 00000000000..484b73e031e --- /dev/null +++ b/app/models/queries/projects/selects/favored.rb @@ -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 diff --git a/app/services/queries/projects/project_queries/set_attributes_service.rb b/app/services/queries/projects/project_queries/set_attributes_service.rb index 8df3a0b28b2..9f8ce3acdae 100644 --- a/app/services/queries/projects/project_queries/set_attributes_service.rb +++ b/app/services/queries/projects/project_queries/set_attributes_service.rb @@ -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 diff --git a/config/initializers/acts_as_favorable.rb b/config/initializers/acts_as_favorable.rb new file mode 100644 index 00000000000..9b17d8f8b48 --- /dev/null +++ b/config/initializers/acts_as_favorable.rb @@ -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 diff --git a/config/initializers/acts_as_watchable.rb b/config/initializers/acts_as_watchable.rb index 1b95b09d305..03ef5dafada 100644 --- a/config/initializers/acts_as_watchable.rb +++ b/config/initializers/acts_as_watchable.rb @@ -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 diff --git a/config/initializers/feature_decisions.rb b/config/initializers/feature_decisions.rb index e98381d9be1..b1bd4c7f2cc 100644 --- a/config/initializers/feature_decisions.rb +++ b/config/initializers/feature_decisions.rb @@ -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 diff --git a/config/locales/en.yml b/config/locales/en.yml index 30d245f1fb6..579d482e4de 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1346,6 +1346,7 @@ Project attributes and sections are defined in the "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] diff --git a/db/migrate/20240404074025_create_favorite.rb b/db/migrate/20240404074025_create_favorite.rb new file mode 100644 index 00000000000..2827dea096c --- /dev/null +++ b/db/migrate/20240404074025_create_favorite.rb @@ -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 diff --git a/frontend/src/app/features/overview/overview.component.ts b/frontend/src/app/features/overview/overview.component.ts index 384f9dc1e16..742139d1936 100644 --- a/frontend/src/app/features/overview/overview.component.ts +++ b/frontend/src/app/features/overview/overview.component.ts @@ -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'; } diff --git a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html index adec64191cb..e1364619802 100644 --- a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html +++ b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html @@ -1,5 +1,6 @@
-
+

diff --git a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.ts b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.ts index a56368d46d9..0267279a917 100644 --- a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.ts +++ b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.ts @@ -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 diff --git a/lib_static/open_project/acts/favorable.rb b/lib_static/open_project/acts/favorable.rb index a1fe27d2fb0..7c830bd8d11 100644 --- a/lib_static/open_project/acts/favorable.rb +++ b/lib_static/open_project/acts/favorable.rb @@ -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 diff --git a/lib_static/open_project/acts/favorable/registry.rb b/lib_static/open_project/acts/favorable/registry.rb new file mode 100644 index 00000000000..34cd454001b --- /dev/null +++ b/lib_static/open_project/acts/favorable/registry.rb @@ -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 diff --git a/lib_static/open_project/acts/favorable/routes.rb b/lib_static/open_project/acts/favorable/routes.rb new file mode 100644 index 00000000000..afd526649cd --- /dev/null +++ b/lib_static/open_project/acts/favorable/routes.rb @@ -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 diff --git a/lib_static/open_project/acts/watchable/registry.rb b/lib_static/open_project/acts/watchable/registry.rb index c233cf1c325..c46e0327b55 100644 --- a/lib_static/open_project/acts/watchable/registry.rb +++ b/lib_static/open_project/acts/watchable/registry.rb @@ -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) diff --git a/lib_static/open_project/acts/watchable/routes.rb b/lib_static/open_project/acts/watchable/routes.rb index d6dd092ba8b..994e52c1d16 100644 --- a/lib_static/open_project/acts/watchable/routes.rb +++ b/lib_static/open_project/acts/watchable/routes.rb @@ -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 diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index 3eb2090bccb..e6c763a1c71 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -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 diff --git a/modules/overviews/app/views/overviews/overviews/show.html.erb b/modules/overviews/app/views/overviews/overviews/show.html.erb index 9971a61bc20..d63c1516f0b 100644 --- a/modules/overviews/app/views/overviews/overviews/show.html.erb +++ b/modules/overviews/app/views/overviews/overviews/show.html.erb @@ -1,3 +1,41 @@ <% content_for :header_tags do %> <% 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 +%> + + diff --git a/spec/features/projects/favorite_spec.rb b/spec/features/projects/favorite_spec.rb new file mode 100644 index 00000000000..997ae476624 --- /dev/null +++ b/spec/features/projects/favorite_spec.rb @@ -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 diff --git a/spec/models/queries/projects/factory_spec.rb b/spec/models/queries/projects/factory_spec.rb index 175111be500..fad11858eaa 100644 --- a/spec/models/queries/projects/factory_spec.rb +++ b/spec/models/queries/projects/factory_spec.rb @@ -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 diff --git a/spec/models/queries/projects/project_query_spec.rb b/spec/models/queries/projects/project_query_spec.rb index 92c4d451f99..0b2615ec6a8 100644 --- a/spec/models/queries/projects/project_query_spec.rb +++ b/spec/models/queries/projects/project_query_spec.rb @@ -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, diff --git a/spec/services/queries/projects/project_queries/set_attributes_service_spec.rb b/spec/services/queries/projects/project_queries/set_attributes_service_spec.rb index eea42daa499..b3eb4feeabd 100644 --- a/spec/services/queries/projects/project_queries/set_attributes_service_spec.rb +++ b/spec/services/queries/projects/project_queries/set_attributes_service_spec.rb @@ -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 diff --git a/spec/support/pages/projects/index.rb b/spec/support/pages/projects/index.rb index c598e24ab6a..20f3dcbc14e 100644 --- a/spec/support/pages/projects/index.rb +++ b/spec/support/pages/projects/index.rb @@ -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 diff --git a/spec/support/shared/with_flag.rb b/spec/support/shared/with_flag.rb index ea95f6417be..583af19a86a 100644 --- a/spec/support/shared/with_flag.rb +++ b/spec/support/shared/with_flag.rb @@ -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