diff --git a/app/models/project.rb b/app/models/project.rb index 8d01e969e0c0..ef5be9571b39 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -85,6 +85,8 @@ class Project < ApplicationRecord }, dependent: :destroy has_many :time_entries, dependent: :delete_all has_many :time_entry_activities_projects, dependent: :delete_all + has_many :cost_types_projects, dependent: :delete_all + has_many :cost_types, through: :cost_types_projects has_many :queries, dependent: :destroy has_many :news, -> { includes(:author) }, dependent: :destroy has_many :categories, -> { order("#{Category.table_name}.name") }, dependent: :delete_all @@ -193,7 +195,8 @@ class Project < ApplicationRecord scope :templated, -> { where(templated: true) } scopes :activated_time_activity, - :visible_with_activated_time_activity + :visible_with_activated_time_activity, + :available_cost_types enum :status_code, { on_track: 0, diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index c1dff6e8bf4d..e63541cb1820 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -784,15 +784,21 @@ }, versions: { caption: :label_version_plural }, repository: { caption: :label_repository }, - time_entry_activities: { caption: :enumeration_activities }, + time_and_costs: { + caption: :"cost_types.settings.time_and_costs", + controller: "/projects/settings/time_entry_activities" + }, storage: { caption: :label_required_disk_storage } } project_menu_items.each do |key, options| menu.push :"settings_#{key}", - { controller: "/projects/settings/#{key}", action: "show" }.merge(options.slice(:action)), + { + controller: options[:controller] || "/projects/settings/#{key}", + action: options[:action] || "show" + }, parent: :settings, - **options.except(:action) + **options.except(:action, :controller) end end diff --git a/db/migrate/20260513120000_add_project_scoping_to_cost_types.rb b/db/migrate/20260513120000_add_project_scoping_to_cost_types.rb new file mode 100644 index 000000000000..c640faa34f61 --- /dev/null +++ b/db/migrate/20260513120000_add_project_scoping_to_cost_types.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 AddProjectScopingToCostTypes < ActiveRecord::Migration[8.0] + def change + add_column :cost_types, :is_for_all, :boolean, default: true, null: false + + create_table :cost_types_projects do |t| + t.references :cost_type, null: false, foreign_key: true + t.references :project, null: false, foreign_key: true + t.timestamps + end + + add_index :cost_types_projects, %i[cost_type_id project_id], unique: true + end +end diff --git a/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_form_modal_component.html.erb b/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_form_modal_component.html.erb new file mode 100644 index 000000000000..3cd24755d16b --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_form_modal_component.html.erb @@ -0,0 +1,56 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 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. +++#%> + +<%= + component_wrapper do + primer_form_with( + model: @cost_type_project_mapping, + url:, + data: { turbo: true }, + method: :post + ) do |form| + concat( + render( + Primer::Alpha::Dialog::Body.new( + id: dialog_body_id, test_selector: dialog_body_id, aria: { label: title }, + classes: "Overlay-body_autocomplete_height" + ) + ) do + render(::CostTypes::CostTypeProjects::CostTypeMappingForm.new(form, project_mapping: @cost_type_project_mapping)) + end + ) + + concat( + render(Primer::Alpha::Dialog::Footer.new(show_divider: false)) do + concat(render(Primer::Beta::Button.new(data: { "close-dialog-id": dialog_id })) { cancel_button_text }) + concat(render(Primer::Beta::Button.new(scheme: :primary, type: :submit)) { submit_button_text }) + end + ) + end + end +%> diff --git a/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_form_modal_component.rb b/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_form_modal_component.rb new file mode 100644 index 000000000000..7ccc1b5c68a2 --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_form_modal_component.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 Admin + module CostTypes + module CostTypeProjects + class NewCostTypeProjectsFormModalComponent < ApplicationComponent + include OpTurbo::Streamable + + DIALOG_ID = "new-cost-type-projects-modal" + DIALOG_BODY_ID = "new-cost-type-projects-modal-body" + + def initialize(cost_type_project_mapping:, cost_type:, **) + @cost_type_project_mapping = cost_type_project_mapping + @cost_type = cost_type + super(@cost_type_project_mapping, **) + end + + private + + def url + url_helpers.admin_cost_type_projects_path(@cost_type) + end + + def dialog_id = DIALOG_ID + def dialog_body_id = DIALOG_BODY_ID + + attr_reader :cost_type_project_mapping, :cost_type + + def title + I18n.t(:label_add_projects) + end + + def cancel_button_text + I18n.t("button_cancel") + end + + def submit_button_text + I18n.t("button_add") + end + end + end + end +end diff --git a/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_modal_component.html.erb b/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_modal_component.html.erb new file mode 100644 index 000000000000..6c45cd543eba --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_modal_component.html.erb @@ -0,0 +1,46 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 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. +++#%> + +<%= + render( + Primer::Alpha::Dialog.new( + id: dialog_id, + title:, + test_selector: dialog_id, + size: :large + ) + ) do |dialog| + dialog.with_header( + show_divider: false, + visually_hide_title: false, + variant: :large + ) + + render(form_modal_component) + end +%> diff --git a/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_modal_component.rb b/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_modal_component.rb new file mode 100644 index 000000000000..ed8bcb69b850 --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_modal_component.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 Admin + module CostTypes + module CostTypeProjects + class NewCostTypeProjectsModalComponent < ApplicationComponent + include OpTurbo::Streamable + + def initialize(cost_type_project_mapping:, cost_type:, **) + @cost_type_project_mapping = cost_type_project_mapping + @cost_type = cost_type + super(@cost_type_project_mapping, **) + end + + def render? + !cost_type.is_for_all? + end + + private + + attr_reader :cost_type_project_mapping, :cost_type + + def dialog_id = NewCostTypeProjectsFormModalComponent::DIALOG_ID + def dialog_body_id = NewCostTypeProjectsFormModalComponent::DIALOG_BODY_ID + + def title + I18n.t(:label_add_projects) + end + + def form_modal_component + Admin::CostTypes::CostTypeProjects::NewCostTypeProjectsFormModalComponent.new( + cost_type_project_mapping:, cost_type: + ) + end + end + end + end +end diff --git a/modules/costs/app/components/admin/cost_types/cost_type_projects/row_component.html.erb b/modules/costs/app/components/admin/cost_types/cost_type_projects/row_component.html.erb new file mode 100644 index 000000000000..c492d478b6c6 --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/cost_type_projects/row_component.html.erb @@ -0,0 +1,41 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 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. +++#%> + +<%= component_wrapper(tag: "tr", class: row_css_class, data: { turbo: true }) do %> + <% columns.each do |column| %> + + <%= column_value(column) %> + + <% end %> + + + <% button_links.each do |link| %> + <%= link %> + <% end %> + +<% end %> diff --git a/modules/costs/app/components/admin/cost_types/cost_type_projects/row_component.rb b/modules/costs/app/components/admin/cost_types/cost_type_projects/row_component.rb new file mode 100644 index 000000000000..6fdf303b13a1 --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/cost_type_projects/row_component.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 Admin + module CostTypes + module CostTypeProjects + class RowComponent < ::Projects::RowComponent + include OpTurbo::Streamable + + def wrapper_uniq_by + "project-#{project.id}" + end + + def more_menu_items + @more_menu_items ||= [more_menu_detach_project].compact + end + + private + + def more_menu_detach_project + { + scheme: :default, + icon: nil, + label: I18n.t("projects.settings.project_custom_fields.actions.remove_from_project"), + href: detach_from_project_url, + data: { turbo_method: :delete } + } + end + + def detach_from_project_url + url_helpers.admin_cost_type_project_path( + cost_type_id: @table.params[:cost_type].id, + cost_types_project: { project_id: project.id }, + page: current_page + ) + end + end + end + end +end diff --git a/modules/costs/app/components/admin/cost_types/cost_type_projects/table_component.rb b/modules/costs/app/components/admin/cost_types/cost_type_projects/table_component.rb new file mode 100644 index 000000000000..cae9780d8420 --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/cost_type_projects/table_component.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 Admin + module CostTypes + module CostTypeProjects + class TableComponent < ::Projects::TableComponent + include ::Projects::Concerns::TableComponent::StreamablePaginationLinksConstraints + + def columns + @columns ||= query.selects.grep_v(Queries::Selects::NotExistingSelect) + end + + def sortable? + false + end + + def use_quick_action_table_headers? + false + end + end + end + end +end diff --git a/modules/costs/app/components/admin/cost_types/edit_form_header_component.html.erb b/modules/costs/app/components/admin/cost_types/edit_form_header_component.html.erb new file mode 100644 index 000000000000..4bc09708f8ff --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/edit_form_header_component.html.erb @@ -0,0 +1,36 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 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. +++#%> + +<%= + render(Primer::OpenProject::PageHeader.new(test_selector: "cost-types--page-header")) do |header| + header.with_title { page_title } + header.with_breadcrumbs(breadcrumbs_items, selected_item_font_weight: :normal) + + helpers.render_tab_header_nav(header, tabs, test_selector: :cost_type_detail_header) + end +%> diff --git a/modules/costs/app/components/admin/cost_types/edit_form_header_component.rb b/modules/costs/app/components/admin/cost_types/edit_form_header_component.rb new file mode 100644 index 000000000000..905695a8dd25 --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/edit_form_header_component.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 Admin + module CostTypes + class EditFormHeaderComponent < ApplicationComponent + def initialize(cost_type:, selected:, **) + @cost_type = cost_type + @selected = selected + super(cost_type, **) + end + + def tabs + return [] unless @cost_type.persisted? + + [ + { + name: "edit", + path: edit_admin_cost_type_path(@cost_type), + label: t(:label_details) + }, + { + name: "rates", + path: rates_admin_cost_type_path(@cost_type), + label: t("cost_types.admin.rates.title") + }, + { + name: "cost_type_projects", + path: admin_cost_type_projects_path(@cost_type), + label: t(:label_project_plural) + } + ] + end + + private + + def page_title + @cost_type.persisted? ? @cost_type.name : "#{t(:label_new)} #{::CostType.model_name.human}" + end + + def breadcrumbs_items + [ + { href: admin_index_path, text: t(:label_administration) }, + { href: admin_cost_types_path, text: t(:label_cost_type_plural) }, + page_title + ] + end + end + end +end diff --git a/modules/costs/app/components/projects/settings/time_and_costs/page_header_component.html.erb b/modules/costs/app/components/projects/settings/time_and_costs/page_header_component.html.erb new file mode 100644 index 000000000000..8a35672b0f4c --- /dev/null +++ b/modules/costs/app/components/projects/settings/time_and_costs/page_header_component.html.erb @@ -0,0 +1,42 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 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. +++#%> + +<%= + render(Primer::OpenProject::PageHeader.new(test_selector: "time-and-costs--page-header")) do |header| + header.with_title { page_title } + header.with_breadcrumbs(breadcrumbs_items, selected_item_font_weight: :normal) + + header.with_tab_nav(label: I18n.t("cost_types.settings.time_and_costs"), test_selector: :time_and_costs_tabs) do |tab_nav| + tabs.each do |tab| + tab_nav.with_tab(selected: tab[:name] == @selected, href: tab[:path]) do |t| + t.with_text { tab[:label] } + end + end + end + end +%> diff --git a/modules/costs/app/components/projects/settings/time_and_costs/page_header_component.rb b/modules/costs/app/components/projects/settings/time_and_costs/page_header_component.rb new file mode 100644 index 000000000000..325aaeb09358 --- /dev/null +++ b/modules/costs/app/components/projects/settings/time_and_costs/page_header_component.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 Projects + module Settings + module TimeAndCosts + class PageHeaderComponent < ApplicationComponent + def initialize(project:, selected:, **) + @project = project + @selected = selected + super(project, **) + end + + def tabs + [ + { + name: :time_entry_activities, + path: helpers.project_settings_time_entry_activities_path(@project), + label: I18n.t(:enumeration_activities) + }, + { + name: :cost_types, + path: helpers.project_settings_cost_types_path(@project), + label: I18n.t("cost_types.settings.cost_types.heading") + } + ] + end + + def page_title + I18n.t("cost_types.settings.time_and_costs") + end + + def breadcrumbs_items + [ + { href: helpers.project_overview_path(@project.id), text: @project.name }, + { href: helpers.project_settings_general_path(@project.id), text: I18n.t(:label_project_settings) }, + page_title + ] + end + end + end + end +end diff --git a/modules/costs/app/contracts/cost_types/cost_type_projects/base_contract.rb b/modules/costs/app/contracts/cost_types/cost_type_projects/base_contract.rb new file mode 100644 index 000000000000..993250ea1ae7 --- /dev/null +++ b/modules/costs/app/contracts/cost_types/cost_type_projects/base_contract.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 CostTypes + module CostTypeProjects + class BaseContract < ::ModelContract + include UnchangedProject + + MANAGE_PERMISSION = :manage_project_activities + + attribute :project_id + attribute :cost_type_id + + validate :validate_manage_allowed_in_source_project + validate :validate_manage_allowed_in_destination_project + validate :not_for_all + + def validate_manage_allowed_in_source_project + if model.new_record? + errors.add :base, :error_unauthorized unless user.allowed_in_project?(MANAGE_PERMISSION, model.project) + return + end + + with_unchanged_project_id do + errors.add :base, :error_unauthorized unless user.allowed_in_project?(MANAGE_PERMISSION, model.project) + end + end + + def validate_manage_allowed_in_destination_project + return if model.new_record? + return unless model.project_id_changed? + + unless user.allowed_in_project?(MANAGE_PERMISSION, model.project) + errors.add :base, :error_unauthorized + end + end + + def not_for_all + return if model.cost_type.nil? || !model.cost_type.is_for_all? + + errors.add :cost_type_id, :is_for_all_cannot_modify + end + end + end +end diff --git a/modules/costs/app/contracts/cost_types/cost_type_projects/update_contract.rb b/modules/costs/app/contracts/cost_types/cost_type_projects/update_contract.rb new file mode 100644 index 000000000000..dbe035b2a839 --- /dev/null +++ b/modules/costs/app/contracts/cost_types/cost_type_projects/update_contract.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 CostTypes + module CostTypeProjects + class UpdateContract < BaseContract + end + end +end diff --git a/modules/costs/app/controllers/admin/cost_types/cost_type_projects_controller.rb b/modules/costs/app/controllers/admin/cost_types/cost_type_projects_controller.rb new file mode 100644 index 000000000000..7440388748be --- /dev/null +++ b/modules/costs/app/controllers/admin/cost_types/cost_type_projects_controller.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 Admin::CostTypes::CostTypeProjectsController < ApplicationController + include OpTurbo::ComponentStream + include FlashMessagesOutputSafetyHelper + + layout "admin" + + before_action :require_admin + before_action :find_cost_type + + before_action :available_cost_types_projects_query, only: %i[index destroy] + before_action :initialize_cost_type_project, only: :new + before_action :find_projects_to_activate_for_cost_type, only: :create + before_action :find_cost_type_project_to_destroy, only: :destroy + + menu_item :cost_types + + def index; end + + def new + respond_with_dialog Admin::CostTypes::CostTypeProjects::NewCostTypeProjectsModalComponent.new( + cost_type_project_mapping: @cost_type_project, + cost_type: @cost_type + ) + end + + def create + create_service = ::CostTypes::CostTypeProjects::BulkCreateService + .new(user: current_user, projects: @projects, model: @cost_type, + include_sub_projects: include_sub_projects?) + .call + + create_service.on_success { render_project_list(url_for_action: :index) } + + create_service.on_failure do + render_error_flash_message_via_turbo_stream( + message: join_flash_messages(create_service.errors) + ) + end + + respond_to_with_turbo_streams(status: create_service.success? ? :ok : :unprocessable_entity) + end + + def destroy + delete_service = ::CostTypes::CostTypeProjects::DeleteService + .new(user: current_user, model: @cost_type_project) + .call + + delete_service.on_success { render_project_list(url_for_action: :index) } + + delete_service.on_failure do + render_error_flash_message_via_turbo_stream( + message: join_flash_messages(delete_service.errors.full_messages) + ) + end + + respond_to_with_turbo_streams(status: delete_service.success? ? :ok : :unprocessable_entity) + end + + private + + def render_project_list(url_for_action: action_name) + update_via_turbo_stream( + component: Admin::CostTypes::CostTypeProjects::TableComponent.new( + query: available_cost_types_projects_query, + params: params.merge({ cost_type: @cost_type, url_for_action: }) + ) + ) + end + + def find_cost_type + @cost_type = CostType.find(params[:cost_type_id]) + end + + def find_projects_to_activate_for_cost_type + if (project_ids = params.to_unsafe_h.dig(:cost_types_project, :project_ids)).present? + @projects = Project.visible.find(project_ids) + else + initialize_cost_type_project + @cost_type_project.errors.add(:project_ids, :blank) + update_via_turbo_stream( + component: Admin::CostTypes::CostTypeProjects::NewCostTypeProjectsFormModalComponent.new( + cost_type_project_mapping: @cost_type_project, + cost_type: @cost_type + ), + status: :bad_request + ) + respond_with_turbo_streams + end + rescue ActiveRecord::RecordNotFound + respond_with_project_not_found_turbo_streams + end + + def find_cost_type_project_to_destroy + @cost_type_project = CostTypesProject.find_by!(cost_type: @cost_type, + project: params[:cost_types_project][:project_id]) + rescue ActiveRecord::RecordNotFound + respond_with_project_not_found_turbo_streams + end + + def available_cost_types_projects_query + @available_cost_types_projects_query = ProjectQuery.new( + name: "cost-types-projects-#{@cost_type.id}" + ) do |query| + query.where(:available_cost_types_projects, "=", [@cost_type.id]) + query.select(:name) + query.order("lft" => "asc") + end + end + + def initialize_cost_type_project + @cost_type_project = ::CostTypes::CostTypeProjects::SetAttributesService + .new(user: current_user, model: CostTypesProject.new, contract_class: EmptyContract) + .call(cost_type: @cost_type) + .result + end + + def respond_with_project_not_found_turbo_streams + render_error_flash_message_via_turbo_stream message: t(:notice_project_not_found) + render_project_list(url_for_action: :index) + + respond_with_turbo_streams + end + + def include_sub_projects? + ActiveRecord::Type::Boolean.new.cast(params.to_unsafe_h.dig(:cost_types_project, :include_sub_projects)) + end +end diff --git a/modules/costs/app/controllers/admin/cost_types_controller.rb b/modules/costs/app/controllers/admin/cost_types_controller.rb index 384efe9e95ff..72087d81f289 100644 --- a/modules/costs/app/controllers/admin/cost_types_controller.rb +++ b/modules/costs/app/controllers/admin/cost_types_controller.rb @@ -38,6 +38,7 @@ class CostTypesController < ApplicationController helper :sort include SortHelper + helper :cost_types include CostTypesHelper @@ -65,17 +66,26 @@ def index # rubocop:disable Metrics/AbcSize render action: "index", layout: !request.xhr? end + def new + @cost_type = CostType.new + + @cost_type.rates.build(valid_from: Time.zone.today) if @cost_type.rates.empty? + + render action: :edit, layout: !request.xhr? + end + def edit render action: :edit, layout: !request.xhr? end - def update - @cost_type.attributes = permitted_params.cost_type + def create # rubocop:disable Metrics/AbcSize + @cost_type = CostType.new(permitted_params.cost_type) if @cost_type.save flash[:notice] = t(:notice_successful_update) redirect_back_or_default({ action: "index" }) else + @cost_type.rates.build(valid_from: Time.zone.today) if @cost_type.rates.empty? render action: :edit, status: :unprocessable_entity, layout: !request.xhr? end rescue ActiveRecord::StaleObjectError @@ -83,22 +93,13 @@ def update flash.now[:error] = t(:notice_locking_conflict) end - def new - @cost_type = CostType.new - - @cost_type.rates.build(valid_from: Time.zone.today) if @cost_type.rates.empty? - - render action: :edit, layout: !request.xhr? - end - - def create # rubocop:disable Metrics/AbcSize - @cost_type = CostType.new(permitted_params.cost_type) + def update # rubocop:disable Metrics/AbcSize + @cost_type.attributes = permitted_params.cost_type if @cost_type.save flash[:notice] = t(:notice_successful_update) - redirect_back_or_default({ action: "index" }) + redirect_to edit_admin_cost_type_path(@cost_type) else - @cost_type.rates.build(valid_from: Time.zone.today) if @cost_type.rates.empty? render action: :edit, status: :unprocessable_entity, layout: !request.xhr? end rescue ActiveRecord::StaleObjectError diff --git a/modules/costs/app/controllers/costlog_controller.rb b/modules/costs/app/controllers/costlog_controller.rb index 0eea746cb9d7..31c6b2ce323c 100644 --- a/modules/costs/app/controllers/costlog_controller.rb +++ b/modules/costs/app/controllers/costlog_controller.rb @@ -37,6 +37,12 @@ class CostlogController < ApplicationController include CostlogHelper def new + unless @project&.cost_types_available? + flash[:error] = I18n.t("cost_types.errors.no_cost_types_available") # rubocop:disable Rails/ActionControllerFlashBeforeRender + redirect_back_or_default(@work_package ? polymorphic_path(@work_package) : project_path(@project)) + return + end + new_default_cost_entry render action: "edit" @@ -140,7 +146,7 @@ def new_default_cost_entry ce.entity = @work_package ce.user = User.current ce.spent_on = Time.zone.today - # notice that cost_type is set to default cost_type in the model + ce.cost_type = CostType.default_for_project(@project) if @project end end diff --git a/modules/costs/app/controllers/projects/settings/cost_types_controller.rb b/modules/costs/app/controllers/projects/settings/cost_types_controller.rb new file mode 100644 index 000000000000..445861be6f90 --- /dev/null +++ b/modules/costs/app/controllers/projects/settings/cost_types_controller.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 Projects::Settings::CostTypesController < Projects::SettingsController + menu_item :settings_time_and_costs + + before_action :find_cost_type, only: :toggle + + def index + @cost_types = CostType.active.order(:name) + end + + def toggle + if @cost_type.is_for_all? + respond_redirect(error: I18n.t("activerecord.errors.messages.is_for_all_cannot_modify")) + return + end + + mapping = CostTypesProject.find_or_initialize_by(project_id: @project.id, cost_type_id: @cost_type.id) + + if mapping.persisted? + mapping.destroy! + else + mapping.save! + end + + respond_redirect + end + + private + + def find_cost_type + @cost_type = CostType.active.find(params[:id]) + end + + def respond_redirect(error: nil) + flash[:error] = error if error + flash[:notice] = I18n.t(:notice_successful_update) unless error + redirect_to project_settings_cost_types_path(@project) + end +end diff --git a/modules/costs/app/controllers/projects/settings/time_entry_activities_controller.rb b/modules/costs/app/controllers/projects/settings/time_entry_activities_controller.rb index 0a877c0a1f7c..352b579df032 100644 --- a/modules/costs/app/controllers/projects/settings/time_entry_activities_controller.rb +++ b/modules/costs/app/controllers/projects/settings/time_entry_activities_controller.rb @@ -29,7 +29,7 @@ #++ class Projects::Settings::TimeEntryActivitiesController < Projects::SettingsController - menu_item :settings_time_entry_activities + menu_item :settings_time_and_costs def update TimeEntryActivitiesProject.upsert_all(update_params, unique_by: %i[project_id activity_id]) diff --git a/modules/costs/app/forms/cost_types/cost_type_projects/cost_type_mapping_form.rb b/modules/costs/app/forms/cost_types/cost_type_projects/cost_type_mapping_form.rb new file mode 100644 index 000000000000..b8d447740d5f --- /dev/null +++ b/modules/costs/app/forms/cost_types/cost_type_projects/cost_type_mapping_form.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 CostTypes + module CostTypeProjects + class CostTypeMappingForm < ApplicationForm + include OpPrimer::ComponentHelpers + + form do |form| + form.project_autocompleter( + name: :id, + label: Project.model_name.human, + visually_hide_label: true, + validation_message: project_ids_error_message, + autocomplete_options: { + appendTo: "##{Admin::CostTypes::CostTypeProjects::NewCostTypeProjectsFormModalComponent::DIALOG_ID}", + with_search_icon: true, + openDirectly: false, + focusDirectly: false, + multiple: true, + dropdownPosition: "bottom", + disabledProjects: projects_with_cost_type_mapping, + inputName: "cost_types_project[project_ids]" + } + ) + + form.check_box( + name: :include_sub_projects, + label: I18n.t(:label_include_sub_projects), + checked: false, + label_arguments: { class: "no-wrap" } + ) + end + + def initialize(project_mapping:) + super() + @project_mapping = project_mapping + end + + private + + def project_ids_error_message + @project_mapping + .errors + .messages_for(:project_ids) + .to_sentence + .presence + end + + def projects_with_cost_type_mapping + CostTypesProject + .where(cost_type_id: @project_mapping.cost_type_id) + .pluck(:project_id) + .to_h { |id| [id, id] } + end + end + end +end diff --git a/modules/costs/app/models/cost_type.rb b/modules/costs/app/models/cost_type.rb index 06f76be82801..01903cd5cda9 100644 --- a/modules/costs/app/models/cost_type.rb +++ b/modules/costs/app/models/cost_type.rb @@ -30,6 +30,8 @@ class CostType < ApplicationRecord has_many :material_budget_items has_many :cost_entries, dependent: :destroy has_many :rates, class_name: "CostRate", dependent: :destroy + has_many :cost_types_projects, dependent: :destroy + has_many :projects, through: :cost_types_projects validates :unit, :unit_plural, presence: true validates :name, presence: true, uniqueness: { case_sensitive: false } @@ -39,12 +41,25 @@ class CostType < ApplicationRecord include ActiveModel::ForbiddenAttributesProtection scope :active, -> { where(deleted_at: nil) } + scope :for_all, -> { where(is_for_all: true) } + scope :available_for_project, ->(project) { + project_id = project.is_a?(Project) ? project.id : project + where(is_for_all: true) + .or(where(id: CostTypesProject.where(project_id:).select(:cost_type_id))) + } # finds the default CostType def self.default CostType.find_by(default: true) || CostType.first end + # Returns the default cost type for the given project, falling back to the first + # cost type available in that project when the global default is not available there. + def self.default_for_project(project) + available = available_for_project(project).active + available.find_by(default: true) || available.first + end + def is_default? default end diff --git a/modules/costs/app/models/cost_types_project.rb b/modules/costs/app/models/cost_types_project.rb new file mode 100644 index 000000000000..018f4a95c122 --- /dev/null +++ b/modules/costs/app/models/cost_types_project.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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. +#++ + +# Join table for cost types on projects, used when CostType#is_for_all is not set +# to find which cost types are activated. +class CostTypesProject < ApplicationRecord + belongs_to :cost_type + belongs_to :project +end diff --git a/modules/costs/app/models/projects/scopes/available_cost_types.rb b/modules/costs/app/models/projects/scopes/available_cost_types.rb new file mode 100644 index 000000000000..d283c80d3a33 --- /dev/null +++ b/modules/costs/app/models/projects/scopes/available_cost_types.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 Projects::Scopes + module AvailableCostTypes + extend ActiveSupport::Concern + + class_methods do + def with_available_cost_types(cost_type_ids) + where(id: cost_types_projects_subquery(cost_type_ids:)) + end + + def without_available_cost_types(cost_type_ids) + where.not(id: cost_types_projects_subquery(cost_type_ids:)) + end + + private + + def cost_types_projects_subquery(cost_type_ids:) + CostTypesProject.select(:project_id) + .where(cost_type_id: cost_type_ids) + end + end + end +end diff --git a/modules/costs/app/models/queries/projects/filters/available_cost_types_projects_filter.rb b/modules/costs/app/models/queries/projects/filters/available_cost_types_projects_filter.rb new file mode 100644 index 000000000000..13918576df93 --- /dev/null +++ b/modules/costs/app/models/queries/projects/filters/available_cost_types_projects_filter.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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::AvailableCostTypesProjectsFilter < Queries::Projects::Filters::Base + def self.key + :available_cost_types_projects + end + + def type + :list + end + + def allowed_values + @allowed_values ||= CostType.where(is_for_all: false).pluck(:name, :id) + end + + def available? + User.current.admin? + end + + def apply_to(_query_scope) + case operator + when "=" + super.with_available_cost_types(values) + when "!" + super.without_available_cost_types(values) + else + raise "unsupported operator" + end + end + + def where + nil + end + + def human_name + I18n.t(:label_available_cost_types_projects) + end +end diff --git a/modules/costs/app/services/cost_types/cost_type_projects/bulk_create_service.rb b/modules/costs/app/services/cost_types/cost_type_projects/bulk_create_service.rb new file mode 100644 index 000000000000..7cdeaa7e528d --- /dev/null +++ b/modules/costs/app/services/cost_types/cost_type_projects/bulk_create_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 CostTypes + module CostTypeProjects + class BulkCreateService < ::BulkServices::ProjectMappings::BaseCreateService + def initialize(user:, projects:, model:, include_sub_projects: false) + mapping_context = ::BulkServices::ProjectMappings::MappingContext.new( + mapping_model_class: CostTypesProject, + model:, + projects:, + model_foreign_key_id:, + include_sub_projects: + ) + super(user:, mapping_context:) + end + + def permission = :manage_project_activities + def model_foreign_key_id = :cost_type_id + end + end +end diff --git a/modules/costs/app/services/cost_types/cost_type_projects/delete_service.rb b/modules/costs/app/services/cost_types/cost_type_projects/delete_service.rb new file mode 100644 index 000000000000..7a6d7e9b2c9c --- /dev/null +++ b/modules/costs/app/services/cost_types/cost_type_projects/delete_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 CostTypes + module CostTypeProjects + class DeleteService < ::BaseServices::Delete + def default_contract_class = CostTypes::CostTypeProjects::UpdateContract + end + end +end diff --git a/modules/costs/app/services/cost_types/cost_type_projects/set_attributes_service.rb b/modules/costs/app/services/cost_types/cost_type_projects/set_attributes_service.rb new file mode 100644 index 000000000000..7397e372e36d --- /dev/null +++ b/modules/costs/app/services/cost_types/cost_type_projects/set_attributes_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 CostTypes + module CostTypeProjects + class SetAttributesService < ::BaseServices::SetAttributes + end + end +end diff --git a/modules/costs/app/views/admin/cost_types/cost_type_projects/index.html.erb b/modules/costs/app/views/admin/cost_types/cost_type_projects/index.html.erb new file mode 100644 index 000000000000..43d5a1887a8d --- /dev/null +++ b/modules/costs/app/views/admin/cost_types/cost_type_projects/index.html.erb @@ -0,0 +1,70 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 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. +++#%> + +<% html_title t(:label_administration), "#{CostType.model_name.human} #{h @cost_type.name}", t(:label_project_plural) %> + +<%= + render( + Admin::CostTypes::EditFormHeaderComponent.new( + cost_type: @cost_type, + selected: :cost_type_projects + ) + ) +%> + +<%= + unless @cost_type.is_for_all? + render(Primer::OpenProject::SubHeader.new(test_selector: "add-projects-sub-header")) do |component| + component.with_action_button(scheme: :primary, + leading_icon: :"op-include-projects", + label: I18n.t(:label_add_projects), + tag: :a, + href: new_admin_cost_type_project_path(@cost_type), + data: { controller: "async-dialog" }) do + I18n.t(:label_add_projects) + end + end + end +%> + +<%= + if @cost_type.is_for_all? + render Primer::Beta::Blankslate.new(border: true) do |component| + component.with_visual_icon(icon: :checklist) + component.with_heading(tag: :h2).with_content(I18n.t("cost_types.admin.cost_type_projects.is_for_all_blank_slate.heading")) + component.with_description { I18n.t("cost_types.admin.cost_type_projects.is_for_all_blank_slate.description") } + end + else + render( + Admin::CostTypes::CostTypeProjects::TableComponent.new( + query: @available_cost_types_projects_query, + params: params.merge({ cost_type: @cost_type }) + ) + ) + end +%> diff --git a/modules/costs/app/views/admin/cost_types/edit.html.erb b/modules/costs/app/views/admin/cost_types/edit.html.erb index 7675ce934bb7..d540009caadf 100644 --- a/modules/costs/app/views/admin/cost_types/edit.html.erb +++ b/modules/costs/app/views/admin/cost_types/edit.html.erb @@ -36,17 +36,12 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <%= - render(Primer::OpenProject::PageHeader.new) do |header| - header.with_title { title } - header.with_breadcrumbs( - [ - { href: admin_index_path, text: t("label_administration") }, - { href: admin_time_settings_path, text: t(:project_module_costs) }, - { href: admin_cost_types_path, text: t(:label_cost_type_plural) }, - title - ] + render( + Admin::CostTypes::EditFormHeaderComponent.new( + cost_type: @cost_type, + selected: :edit ) - end + ) %> <%= labelled_tabular_form_for @cost_type, @@ -67,6 +62,9 @@ See COPYRIGHT and LICENSE files for more details.
<%= f.check_box :default %>
+
+ <%= f.check_box :is_for_all %> +

<%= t :caption_rate_history %>

diff --git a/modules/costs/app/views/projects/settings/cost_types/index.html.erb b/modules/costs/app/views/projects/settings/cost_types/index.html.erb new file mode 100644 index 000000000000..cc8a2930862c --- /dev/null +++ b/modules/costs/app/views/projects/settings/cost_types/index.html.erb @@ -0,0 +1,77 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 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. +++#%> + +<%= + render( + Projects::Settings::TimeAndCosts::PageHeaderComponent.new( + project: @project, + selected: :cost_types + ) + ) +%> + +<% enabled_ids = CostTypesProject.where(project_id: @project.id).pluck(:cost_type_id).to_set %> + +<% if @cost_types.empty? %> + <%= + render Primer::Beta::Blankslate.new(border: true) do |c| + c.with_visual_icon(icon: :checklist) + c.with_heading(tag: :h2).with_content(I18n.t("cost_types.settings.cost_types.heading")) + c.with_description { I18n.t("cost_types.admin.cost_type_projects.no_projects.description") } + end + %> +<% else %> + + + + + + + + + <% @cost_types.each do |cost_type| %> + + + + + <% end %> + +
<%= CostType.model_name.human %>
<%= h cost_type.name %> + <% if cost_type.is_for_all? %> + <%= t(:label_for_all) rescue "For all projects" %> + <% else %> + <%= button_to( + enabled_ids.include?(cost_type.id) ? t(:button_disable) : t(:button_enable), + toggle_project_settings_cost_type_path(@project, cost_type), + method: :put, + class: "button", + form: { data: { turbo: false } } + ) %> + <% end %> +
+<% end %> diff --git a/modules/costs/app/views/projects/settings/time_entry_activities/show.html.erb b/modules/costs/app/views/projects/settings/time_entry_activities/show.html.erb index 2a5237bea489..3582d7e9aff6 100644 --- a/modules/costs/app/views/projects/settings/time_entry_activities/show.html.erb +++ b/modules/costs/app/views/projects/settings/time_entry_activities/show.html.erb @@ -28,14 +28,12 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= - render Primer::OpenProject::PageHeader.new do |header| - header.with_title { t(:enumeration_activities) } - header.with_breadcrumbs( - [{ href: project_overview_path(@project.id), text: @project.name }, - { href: project_settings_general_path(@project.id), text: I18n.t(:label_project_settings) }, - t(:enumeration_activities)] + render( + Projects::Settings::TimeAndCosts::PageHeaderComponent.new( + project: @project, + selected: :time_entry_activities ) - end + ) %> <% if TimeEntryActivity.any? %> diff --git a/modules/costs/config/locales/en.yml b/modules/costs/config/locales/en.yml index cd8642843f37..f4e8385ed956 100644 --- a/modules/costs/config/locales/en.yml +++ b/modules/costs/config/locales/en.yml @@ -47,6 +47,8 @@ en: unit: "Unit name" unit_plural: "Pluralized unit name" default: "Cost type by default" + is_for_all: "For all projects" + rates: "Rates" work_package: costs_by_type: "Spent units" labor_costs: "Labor costs" @@ -252,6 +254,24 @@ en: validation: start_time_different_date: "Date part of startTime (%{start_time}) must be the same as the spentOn (%{spent_on}) date." + label_available_cost_types_projects: "Available cost types projects" + cost_types: + errors: + no_cost_types_available: "No cost types are available in this project. Please contact an administrator." + admin: + cost_type_projects: + is_for_all_blank_slate: + heading: "This cost type is enabled in all projects" + description: "Uncheck \"For all projects\" on the details tab to limit this cost type to specific projects." + no_projects: + heading: "No projects assigned" + description: "Add projects so this cost type can be used in them." + settings: + time_and_costs: "Time & Costs" + cost_types: + heading: "Cost types" + none_active: "No cost types are currently active in this project." + costs: widgets: actual_costs: diff --git a/modules/costs/config/routes.rb b/modules/costs/config/routes.rb index e3eea05aa91f..5fb911cbefdc 100644 --- a/modules/costs/config/routes.rb +++ b/modules/costs/config/routes.rb @@ -62,6 +62,9 @@ scope "projects/:project_id", as: "project", module: "projects" do namespace "settings" do resource :time_entry_activities, only: %i[show update] + resources :cost_types, only: %i[index] do + member { put :toggle } + end end end @@ -93,6 +96,11 @@ put :set_rate patch :restore end + + scope module: :cost_types do + resources :projects, controller: :cost_type_projects, only: %i[index new create] + resource :project, controller: :cost_type_projects, only: :destroy + end end resource :costs, diff --git a/modules/costs/lib/costs/engine.rb b/modules/costs/lib/costs/engine.rb index 1fe57a590fd1..ec6f54e3856f 100644 --- a/modules/costs/lib/costs/engine.rb +++ b/modules/costs/lib/costs/engine.rb @@ -67,7 +67,10 @@ class Engine < ::Rails::Engine require: :member permission :manage_project_activities, - { "projects/settings/time_entry_activities": %i[show update] }, + { + "projects/settings/time_entry_activities": %i[show update], + "projects/settings/cost_types": %i[index toggle] + }, permissible_on: :project, require: :member @@ -222,6 +225,7 @@ class Engine < ::Rails::Engine current_user.allowed_in_project?(:log_own_costs, represented.project) } do next unless represented.costs_enabled? && represented.persisted? + next unless represented.project&.cost_types_available? { href: new_work_packages_cost_entry_path(represented), @@ -349,6 +353,10 @@ class Engine < ::Rails::Engine ::Queries::Register.register(::Query) do select Costs::QueryCurrencySelect end + + ::Queries::Register.register(::ProjectQuery) do + filter ::Queries::Projects::Filters::AvailableCostTypesProjectsFilter + end end end end diff --git a/modules/costs/lib/costs/patches/permitted_params_patch.rb b/modules/costs/lib/costs/patches/permitted_params_patch.rb index 5f5d9f9dd30b..b414babec7f5 100644 --- a/modules/costs/lib/costs/patches/permitted_params_patch.rb +++ b/modules/costs/lib/costs/patches/permitted_params_patch.rb @@ -56,6 +56,7 @@ def cost_type :unit, :unit_plural, :default, + :is_for_all, { new_rate_attributes: %i[valid_from rate] }, existing_rate_attributes: %i[valid_from rate]) end diff --git a/modules/costs/lib/costs/patches/project_patch.rb b/modules/costs/lib/costs/patches/project_patch.rb index b0b1402e338b..aa0f32a1bb77 100644 --- a/modules/costs/lib/costs/patches/project_patch.rb +++ b/modules/costs/lib/costs/patches/project_patch.rb @@ -49,5 +49,9 @@ module InstanceMethods def costs_enabled? module_enabled?(:costs) end + + def cost_types_available? + CostType.available_for_project(self).active.exists? + end end end diff --git a/modules/costs/spec/controllers/costlog_controller_spec.rb b/modules/costs/spec/controllers/costlog_controller_spec.rb index c6b7f11b81c2..c194024171b7 100644 --- a/modules/costs/spec/controllers/costlog_controller_spec.rb +++ b/modules/costs/spec/controllers/costlog_controller_spec.rb @@ -132,7 +132,10 @@ def disable_flash_sweep end describe "WHEN user allowed to create new cost_entry" do + let(:expected_cost_type) { cost_type } + before do + cost_type.save! grant_current_user_permissions user, %i[view_project view_work_packages log_costs] end @@ -154,7 +157,10 @@ def disable_flash_sweep end describe "WHEN user is allowed to create new own cost_entry" do + let(:expected_cost_type) { cost_type } + before do + cost_type.save! grant_current_user_permissions user, %i[view_project view_work_packages log_own_costs] end @@ -172,6 +178,53 @@ def disable_flash_sweep describe "WHEN user is not a project member" do it_behaves_like "not_found new" end + + describe "WHEN no cost type is available in the project" do + let(:scoped_cost_type) { create(:cost_type, is_for_all: false) } + + before do + CostType.destroy_all + scoped_cost_type # only project-scoped cost type, not mapped to this project + grant_current_user_permissions user, %i[view_project view_work_packages log_costs] + get :new, params: + end + + it "redirects with an error flash explaining no cost types are available" do + expect(response).to be_redirect + expect(flash[:error]).to eq(I18n.t("cost_types.errors.no_cost_types_available")) + end + end + + describe "WHEN the project's default cost type is global" do + let(:expected_cost_type) { cost_type } + + before do + CostType.destroy_all + cost_type.is_for_all = true + cost_type.default = true + cost_type.save! + grant_current_user_permissions user, %i[view_project view_work_packages log_costs] + end + + it_behaves_like "successful new" + end + + describe "WHEN the global default cost type is unavailable in the project, " \ + "but another non-global cost type is enabled" do + let(:scoped_cost_type) { create(:cost_type, is_for_all: false) } + let(:global_default) { create(:cost_type, is_for_all: false, default: true) } + let(:expected_cost_type) { scoped_cost_type } + + before do + CostType.destroy_all + global_default + scoped_cost_type + CostTypesProject.create!(project:, cost_type: scoped_cost_type) + grant_current_user_permissions user, %i[view_project view_work_packages log_costs] + end + + it_behaves_like "successful new" + end end describe "GET edit" do diff --git a/modules/costs/spec/features/costs_context_menu_spec.rb b/modules/costs/spec/features/costs_context_menu_spec.rb index f075b3d1484e..77780e0faafd 100644 --- a/modules/costs/spec/features/costs_context_menu_spec.rb +++ b/modules/costs/spec/features/costs_context_menu_spec.rb @@ -3,6 +3,7 @@ RSpec.describe "Work package table log unit costs", :js do let(:user) { create(:admin) } let(:work_package) { create(:work_package) } + let!(:cost_type) { create(:cost_type, is_for_all: true) } let(:wp_table) { Pages::WorkPackagesTable.new } let(:menu) { Components::WorkPackages::ContextMenu.new } diff --git a/modules/costs/spec/features/time_entry/activity_spec.rb b/modules/costs/spec/features/time_entry/activity_spec.rb index 5cbf6029139d..3cd636ed305d 100644 --- a/modules/costs/spec/features/time_entry/activity_spec.rb +++ b/modules/costs/spec/features/time_entry/activity_spec.rb @@ -62,7 +62,7 @@ visit project_settings_general_path(project) - click_on "Time tracking activities" + click_on I18n.t("cost_types.settings.time_and_costs") expect(page).to have_field("Development", checked: true) diff --git a/modules/costs/spec/lib/api/v3/work_packages/work_package_representer_spec.rb b/modules/costs/spec/lib/api/v3/work_packages/work_package_representer_spec.rb index 1e742c017bbd..1cdba742a74d 100644 --- a/modules/costs/spec/lib/api/v3/work_packages/work_package_representer_spec.rb +++ b/modules/costs/spec/lib/api/v3/work_packages/work_package_representer_spec.rb @@ -73,6 +73,11 @@ embed_links: true) end + # Create a cost type that enables the log unit cost paths + let!(:cost_type) do + create(:cost_type, is_for_all: true) + end + before do allow(User).to receive(:current).and_return user end @@ -301,6 +306,38 @@ end end end + + describe "logCosts gating by available cost types" do + let(:additional_permissions) { %i[log_costs view_time_entries view_cost_entries view_cost_rates] } + + before { CostType.destroy_all } + + context "when at least one cost type is available in the project" do + before { create(:cost_type, is_for_all: true) } + + it "has the logCosts link" do + expect(subject).to have_json_path("_links/logCosts/href") + end + end + + context "when no cost type is available in the project" do + before { create(:cost_type, is_for_all: false) } + + it "omits the logCosts link" do + expect(subject).not_to have_json_path("_links/logCosts/href") + end + end + + context "when a non-global cost type is explicitly enabled in the project" do + let!(:scoped_cost_type) { create(:cost_type, is_for_all: false) } + + before { CostTypesProject.create!(project:, cost_type: scoped_cost_type) } + + it "has the logCosts link" do + expect(subject).to have_json_path("_links/logCosts/href") + end + end + end end describe "costs module disabled" do diff --git a/modules/costs/spec/models/cost_type_spec.rb b/modules/costs/spec/models/cost_type_spec.rb index 2c3b2e5879ec..4f817c4b535f 100644 --- a/modules/costs/spec/models/cost_type_spec.rb +++ b/modules/costs/spec/models/cost_type_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -29,30 +31,21 @@ require_relative "../spec_helper" RSpec.describe CostType do - let(:klass) { CostType } let(:cost_type) do - klass.new name: "ct1", - unit: "singular", - unit_plural: "plural" - end - - before do - # as the spec_helper loads fixtures and they are probably needed by other tests - # we delete them here so they do not interfere. - # on the long run, fixtures should be removed - - CostType.destroy_all + described_class.new name: "ct1", + unit: "singular", + unit_plural: "plural" end describe "class" do describe "active" do describe "WHEN a CostType instance is deleted" do before do - cost_type.deleted_at = Time.now + cost_type.deleted_at = Time.zone.now cost_type.save! end - it { expect(klass.active.size).to eq(0) } + it { expect(described_class.active.size).to eq(0) } end describe "WHEN a CostType instance is not deleted" do @@ -60,8 +53,59 @@ cost_type.save! end - it { expect(klass.active.size).to eq(1) } - it { expect(klass.active[0]).to eq(cost_type) } + it { expect(described_class.active.size).to eq(1) } + it { expect(described_class.active[0]).to eq(cost_type) } + end + end + end + + describe ".available_for_project" do + let(:project) { create(:project) } + let(:other_project) { create(:project) } + let!(:global_ct) { create(:cost_type, is_for_all: true) } + let!(:scoped_ct) { create(:cost_type, is_for_all: false) } + let!(:unrelated_ct) { create(:cost_type, is_for_all: false) } + + before do + CostTypesProject.create!(cost_type: scoped_ct, project: project) + CostTypesProject.create!(cost_type: unrelated_ct, project: other_project) + end + + it "returns global cost types plus those explicitly mapped to the project" do + expect(described_class.available_for_project(project)).to contain_exactly(global_ct, scoped_ct) + end + + it "accepts a project_id integer too" do + expect(described_class.available_for_project(project.id)).to contain_exactly(global_ct, scoped_ct) + end + end + + describe ".default_for_project" do + let(:project) { create(:project) } + + context "when the global default is available in the project" do + let!(:default_ct) { create(:cost_type, is_for_all: true, default: true) } + let!(:_other) { create(:cost_type, is_for_all: true) } + + it "returns the default" do + expect(described_class.default_for_project(project)).to eq(default_ct) + end + end + + context "when the global default is NOT available in the project" do + let!(:default_ct) { create(:cost_type, is_for_all: false, default: true) } + let!(:available) { create(:cost_type, is_for_all: true) } + + it "falls back to the first available cost type" do + expect(described_class.default_for_project(project)).to eq(available) + end + end + + context "when no cost type is available in the project" do + let!(:_unrelated) { create(:cost_type, is_for_all: false) } + + it "returns nil" do + expect(described_class.default_for_project(project)).to be_nil end end end diff --git a/modules/costs/spec/models/cost_types_project_spec.rb b/modules/costs/spec/models/cost_types_project_spec.rb new file mode 100644 index 000000000000..5248e6e5f39c --- /dev/null +++ b/modules/costs/spec/models/cost_types_project_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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_relative "../spec_helper" + +RSpec.describe CostTypesProject do + let(:project) { create(:project) } + let(:cost_type) { create(:cost_type, is_for_all: false) } + + it "creates a mapping with both belongs_to associations" do + mapping = described_class.create!(project:, cost_type:) + expect(mapping.project).to eq(project) + expect(mapping.cost_type).to eq(cost_type) + end + + it "is destroyed when the cost type is destroyed" do + described_class.create!(project:, cost_type:) + expect { cost_type.destroy }.to change(described_class, :count).by(-1) + end + + it "is deleted when the project is destroyed" do + described_class.create!(project:, cost_type:) + expect { project.destroy }.to change(described_class, :count).by(-1) + end + + it "enforces uniqueness of (project_id, cost_type_id) at the DB level" do + described_class.create!(project:, cost_type:) + expect { described_class.create!(project:, cost_type:) } + .to raise_error(ActiveRecord::RecordNotUnique) + end +end diff --git a/modules/costs/spec/models/project_cost_types_available_spec.rb b/modules/costs/spec/models/project_cost_types_available_spec.rb new file mode 100644 index 000000000000..83ccadc2af34 --- /dev/null +++ b/modules/costs/spec/models/project_cost_types_available_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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_relative "../spec_helper" + +RSpec.describe Project, "#cost_types_available?" do + let(:project) { create(:project) } + + before { CostType.destroy_all } + + it "is true when at least one cost type is for all projects" do + create(:cost_type, is_for_all: true) + expect(project.cost_types_available?).to be true + end + + it "is true when a cost type is explicitly enabled in the project" do + cost_type = create(:cost_type, is_for_all: false) + CostTypesProject.create!(project:, cost_type:) + expect(project.cost_types_available?).to be true + end + + it "is false when there are no cost types enabled in the project" do + create(:cost_type, is_for_all: false) + expect(project.cost_types_available?).to be false + end + + it "ignores soft-deleted cost types" do + create(:cost_type, :deleted, is_for_all: true) + expect(project.cost_types_available?).to be false + end +end diff --git a/modules/costs/spec/models/queries/projects/filters/available_cost_types_projects_filter_spec.rb b/modules/costs/spec/models/queries/projects/filters/available_cost_types_projects_filter_spec.rb new file mode 100644 index 000000000000..d994a6393090 --- /dev/null +++ b/modules/costs/spec/models/queries/projects/filters/available_cost_types_projects_filter_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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_relative "../../../../spec_helper" + +RSpec.describe Queries::Projects::Filters::AvailableCostTypesProjectsFilter do + let(:instance) { described_class.create!(name: described_class.key) } + + describe ".key" do + it { expect(described_class.key).to eq(:available_cost_types_projects) } + end + + describe "#allowed_values" do + before { CostType.destroy_all } + + let!(:scoped_a) { create(:cost_type, is_for_all: false, name: "Disk") } + let!(:scoped_b) { create(:cost_type, is_for_all: false, name: "License") } + let!(:_global) { create(:cost_type, is_for_all: true, name: "Travel") } + + it "lists only non-global cost types (so filter validates even before any mapping exists)" do + expect(instance.allowed_values).to contain_exactly( + ["Disk", scoped_a.id], + ["License", scoped_b.id] + ) + end + end + + describe "filter is registered with ProjectQuery" do + it "is in the ProjectQuery filter registry" do + expect(Queries::Register.filters[ProjectQuery]).to include(described_class) + end + end + + describe "applying the filter via ProjectQuery" do + let(:admin) { create(:admin) } + let!(:cost_type) { create(:cost_type, is_for_all: false) } + let!(:mapped_project) { create(:project) } + let!(:unmapped_project) { create(:project) } + + before do + login_as(admin) + CostTypesProject.create!(cost_type:, project: mapped_project) + end + + it "returns only projects mapped to the given cost type" do + query = ProjectQuery.new(name: "t") do |q| + q.where(:available_cost_types_projects, "=", [cost_type.id]) + q.select(:name) + end + + expect(query).to be_valid + expect(query.results.pluck(:id)).to contain_exactly(mapped_project.id) + end + + it "returns no projects when the cost type has no mappings (and query stays valid)" do + cost_type_without_mappings = create(:cost_type, is_for_all: false) + + query = ProjectQuery.new(name: "t2") do |q| + q.where(:available_cost_types_projects, "=", [cost_type_without_mappings.id]) + q.select(:name) + end + + expect(query).to be_valid + expect(query.results).to be_empty + end + end +end diff --git a/modules/costs/spec/requests/projects/settings/cost_types_controller_spec.rb b/modules/costs/spec/requests/projects/settings/cost_types_controller_spec.rb new file mode 100644 index 000000000000..466a094ed030 --- /dev/null +++ b/modules/costs/spec/requests/projects/settings/cost_types_controller_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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_relative "../../../spec_helper" + +RSpec.describe Projects::Settings::CostTypesController, :skip_csrf, type: :rails_request do + shared_let(:project) { create(:project, enabled_module_names: %i[costs]) } + + let(:permissions) { %i[manage_project_activities] } + let(:user) { create(:user, member_with_permissions: { project => permissions }) } + + let!(:global_ct) { create(:cost_type, is_for_all: true) } + let!(:scoped_ct) { create(:cost_type, is_for_all: false) } + + before { login_as(user) } + + describe "GET #index" do + it "renders the list of cost types" do + get project_settings_cost_types_path(project) + expect(response).to have_http_status(:ok) + end + end + + describe "PUT #toggle" do + context "with a non-global cost type not yet enabled in the project" do + it "creates the mapping" do + expect do + put toggle_project_settings_cost_type_path(project, scoped_ct) + end.to change { CostTypesProject.where(project:, cost_type: scoped_ct).count }.from(0).to(1) + expect(response).to redirect_to(project_settings_cost_types_path(project)) + end + end + + context "with a non-global cost type already enabled" do + before { CostTypesProject.create!(project:, cost_type: scoped_ct) } + + it "removes the mapping" do + expect do + put toggle_project_settings_cost_type_path(project, scoped_ct) + end.to change { CostTypesProject.where(project:, cost_type: scoped_ct).count }.from(1).to(0) + end + end + + context "with a global cost type" do + it "rejects toggling" do + expect do + put toggle_project_settings_cost_type_path(project, global_ct) + end.not_to change(CostTypesProject, :count) + expect(flash[:error]).to be_present + end + end + end + + context "when user lacks :manage_project_activities" do + let(:permissions) { %i[view_work_packages] } + + it "blocks index" do + get project_settings_cost_types_path(project) + expect(response).to have_http_status(:forbidden) + end + end +end diff --git a/modules/costs/spec/services/cost_types/cost_type_projects/bulk_create_service_spec.rb b/modules/costs/spec/services/cost_types/cost_type_projects/bulk_create_service_spec.rb new file mode 100644 index 000000000000..52034023767b --- /dev/null +++ b/modules/costs/spec/services/cost_types/cost_type_projects/bulk_create_service_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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_relative "../../../spec_helper" +require Rails.root.join("spec/services/bulk_services/project_mappings/behaves_like_bulk_project_mapping_create_service") + +RSpec.describe CostTypes::CostTypeProjects::BulkCreateService do + shared_let(:cost_type) { create(:cost_type, is_for_all: false) } + + it_behaves_like "BulkServices project mappings create service" do + let(:model) { cost_type } + let(:model_mapping_class) { CostTypesProject } + let(:model_foreign_key_id) { :cost_type_id } + let(:required_permission) { :manage_project_activities } + end +end diff --git a/modules/costs/spec/services/cost_types/cost_type_projects/delete_service_spec.rb b/modules/costs/spec/services/cost_types/cost_type_projects/delete_service_spec.rb new file mode 100644 index 000000000000..2c0d8e4ecb7d --- /dev/null +++ b/modules/costs/spec/services/cost_types/cost_type_projects/delete_service_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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_relative "../../../spec_helper" + +RSpec.describe CostTypes::CostTypeProjects::DeleteService do + shared_let(:project) { create(:project) } + shared_let(:cost_type) { create(:cost_type, is_for_all: false) } + + let!(:mapping) { CostTypesProject.create!(project:, cost_type:) } + + context "with an admin user" do + let(:user) { create(:admin) } + + it "removes the mapping" do + expect { described_class.new(user:, model: mapping).call } + .to change(CostTypesProject, :count).by(-1) + end + end + + context "with a user lacking permissions" do + let(:user) { create(:user, member_with_permissions: { project => %i[view_work_packages] }) } + + it "does not remove the mapping" do + result = described_class.new(user:, model: mapping).call + + expect(result).to be_failure + expect(CostTypesProject.where(id: mapping.id)).to exist + end + end + + context "when the cost type is is_for_all" do + let(:user) { create(:admin) } + + before { cost_type.update!(is_for_all: true) } + + it "refuses to delete the mapping" do + result = described_class.new(user:, model: mapping).call + + expect(result).to be_failure + expect(result.errors.symbols_for(:cost_type_id)).to include(:is_for_all_cannot_modify) + expect(CostTypesProject.where(id: mapping.id)).to exist + end + end +end