Merge pull request #19325 from opf/implementation/64620-create-projects_tab_controller

[#64620] introduce work package type projects tab controller
This commit is contained in:
Eric Schubert
2025-06-27 15:55:19 +02:00
committed by GitHub
11 changed files with 450 additions and 111 deletions
@@ -0,0 +1,61 @@
<%#-- 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.
++#%>
<%=
settings_primer_form_with(**form_options) do |form|
component_wrapper do
flex_layout do |container|
container.with_row(mb: 3) do
render(
Primer::OpenProject::TreeView.new(
expanded: true,
data: {
"admin--work-package-type-projects-target": "treeView",
action: "click->admin--work-package-type-projects#updateSelectedProjects"
},
select_variant: :multiple
)
) do |tree_view|
build_project_tree(tree_view)
end
end
container.with_row do
form.hidden_field :project_ids,
value: "[]",
data: { "admin--work-package-type-projects-target": "selectedProjects" }
end
container.with_row do
render(Primer::Beta::Button.new(scheme: :primary, type: :submit)) { t(:button_save) }
end
end
end
end
%>
@@ -0,0 +1,82 @@
# 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 WorkPackageTypes
class ProjectsComponent < ApplicationComponent
include ApplicationHelper
include OpPrimer::ComponentHelpers
include OpTurbo::Streamable
def form_options
{
url: type_projects_path(type_id: model.id),
method: :put,
model:,
data: {
controller: "admin--work-package-type-projects",
"admin--work-package-type-projects-initially-selected-projects-value": model.projects.pluck(:id).join(",")
}
}
end
def projects = options[:projects]
def build_project_tree(tree)
nested_project_list = Project.build_projects_hierarchy(projects)
add_sub_tree(tree, nested_project_list)
end
private
def add_sub_tree(tree, project_list)
project_list.each do |project_hash|
if project_hash[:children].empty?
tree.with_leaf(**item_options(project_hash[:project]))
else
tree.with_sub_tree(expanded: true,
select_strategy: :self,
**item_options(project_hash[:project])) do |sub_tree|
add_sub_tree(sub_tree, project_hash[:children])
end
end
end
end
def item_options(item)
{
select_variant: :multiple,
label: item.name,
data: { project_id: item.id },
checked: model.projects.include?(item)
}
end
end
end
@@ -0,0 +1,78 @@
# 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 WorkPackageTypes
class ProjectsTabController < ApplicationController
layout "admin"
before_action :require_admin
before_action :find_type
before_action :load_projects, only: :edit
current_menu_item [:edit] do
:types
end
def edit; end
def update
result = UpdateService.new(user: current_user, model: @type, contract_class: UpdateProjectsContract)
.call(permitted_project_params)
if result.success?
redirect_to edit_type_projects_path(type_id: @type.id), notice: I18n.t(:notice_successful_update)
else
flash_error(result)
load_projects
render :edit, status: :unprocessable_entity
end
end
private
def flash_error(result)
flash.now[:error] = result.errors.messages_for(:project_ids).to_sentence
end
def load_projects
@projects = Project.all
end
def find_type
@type = ::Type.find(params[:type_id])
end
def permitted_project_params
# TODO: once the input is correctly delivered just return: params.expect(type: [:project_ids])
{ project_ids: JSON.parse(params.expect(type: [:project_ids])[:project_ids]) }
end
end
end
+2 -2
View File
@@ -53,8 +53,8 @@ module ::TypesHelper
},
{
name: "projects",
partial: "types/form/projects",
path: edit_tab_type_path(id: @type.id, tab: :projects),
path: edit_type_projects_path(type_id: @type.id),
view_component: WorkPackageTypes::ProjectsComponent,
label: I18n.t("types.edit.projects.tab")
},
{
-62
View File
@@ -1,62 +0,0 @@
<%#-- 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.
++#%>
<section class="form--section">
<div class="grid-block wrap">
<div class="grid-content small-12 large-6">
<% if @projects.any? %>
<fieldset class="form--fieldset" id="type_project_ids">
<legend class="form--fieldset-legend">
<%= t("types.edit.projects.enabled_projects") %>
</legend>
<div class="form--toolbar">
<span class="form--toolbar-item">
(<%= check_all_links "type_project_ids" %>)
</span>
</div>
<%= project_nested_ul(@projects) do |p|
content_tag(
"label",
check_box_tag("type[project_ids][]", p.id, @type.projects.include?(p), id: nil) +
" " + h(p), class: "form--label-with-check-box"
)
end %>
<%= hidden_field_tag("type[project_ids][]", "", id: nil) %>
</fieldset>
<% end %>
</div>
</div>
</section>
<div class="grid-block">
<div class="generic-table--action-buttons">
<%= styled_button_tag t(@type.new_record? ? :button_create : :button_save),
class: "-primary -with-icon icon-checkmark" %>
</div>
</div>
@@ -0,0 +1,34 @@
<%#-- 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), "#{t(:label_edit)} #{t(:label_work_package_types)} #{h @type.name}" %>
<%= render ::Types::EditPageHeaderComponent.new(type: @type, tabs: types_tabs) %>
<%= render WorkPackageTypes::ProjectsComponent.new(@type, projects: @projects) %>
+2
View File
@@ -138,6 +138,8 @@ Rails.application.routes.draw do
get "/roles/workflow/:id/:role_id/:type_id" => "roles#workflow"
resources :types do
resource :projects, controller: "work_package_types/projects_tab", only: %i[update edit]
member do
get "edit/:tab" => "types#edit", as: "edit_tab"
match "update/:tab" => "types#update", as: "update_tab", via: %i[post patch]
@@ -0,0 +1,102 @@
/*
* -- 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.
* ++
*/
import { Controller } from '@hotwired/stimulus';
export default class WorkPackageTypeProjectsController extends Controller {
static targets = [
'selectedProjects',
'treeView',
];
static values = {
initiallySelectedProjects: String,
};
declare initiallySelectedProjectsValue:string;
declare readonly treeViewTarget:HTMLElement;
declare readonly selectedProjectsTarget:HTMLInputElement;
connect():void {
this.addProjectIds(this.initiallySelectedProjectsValue.split(','));
}
updateSelectedProjects(ev:PointerEvent):void {
const target = ev.target;
if (!this.isHTMLElement(target)) {
return;
}
const projectItem = target.closest('.TreeViewItemContent');
if (!this.isHTMLElement(projectItem)) {
return;
}
const projectId = projectItem.dataset.projectId;
const checked = projectItem.ariaChecked;
if (!projectId || !checked) {
return;
}
if (checked === 'false') {
// 'false' means it was now changed to true -> selected
this.addProjectIds([projectId]);
} else {
this.removeProjectIds([projectId]);
}
}
private addProjectIds(ids:string[]):void {
if (!this.selectedProjectsTarget) {
return;
}
const currentIds = JSON.parse(this.selectedProjectsTarget.value) as string[];
const distinctIds = [...new Set(currentIds), ...new Set(ids)].filter((id) => id.length > 0);
this.selectedProjectsTarget.value = JSON.stringify(distinctIds);
}
private removeProjectIds(ids:string[]):void {
if (!this.selectedProjectsTarget) {
return;
}
const currentIds = JSON.parse(this.selectedProjectsTarget.value) as string[];
const newIds = currentIds.filter((id) => !ids.includes(id));
this.selectedProjectsTarget.value = JSON.stringify(newIds);
}
private isHTMLElement(obj:unknown):obj is HTMLElement {
return obj instanceof HTMLElement;
}
}
@@ -28,7 +28,7 @@ module OpenProject
sub_tree.with_sub_tree(label: "OpenProject GmbH",
expanded: expanded,
elect_variant: select_variant,
select_variant: select_variant,
select_strategy: select_strategy) do |sub_tree2|
sub_tree2.with_leaf(label: "HR", select_variant: select_variant)
sub_tree2.with_leaf(label: "Development", current: true, select_variant: select_variant)
+1 -46
View File
@@ -23,7 +23,7 @@
#
# 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.
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
@@ -226,27 +226,6 @@ RSpec.describe TypesController do
it { expect(response.body).to have_css "input[@name='type[is_milestone]'][@value='1'][@checked='checked']" }
end
describe "GET edit projects" do
render_views
let(:type) do
create(:type, name: "My type",
is_milestone: true,
projects: [project])
end
before do
get "edit", params: { id: type.id, tab: :projects }
end
it { expect(response).to have_http_status(:ok) }
it { expect(response).to render_template "edit" }
it { expect(response).to render_template "types/form/_projects" }
it {
expect(response.body).to have_css "input[@name='type[project_ids][]'][@value='#{project.id}'][@checked='checked']"
}
end
describe "PATCH update" do
let(:project2) { create(:project) }
let(:type) do
@@ -293,30 +272,6 @@ RSpec.describe TypesController do
it { expect(response).to have_http_status(:unprocessable_entity) }
it { expect(response).to render_template "edit" }
end
describe "WITH projects removed" do
let(:params) do
{ "id" => type.id,
"type" => { project_ids: [""] },
"tab" => "projects" }
end
before do
patch :update, params:
end
it { expect(response).to be_redirect }
it do
expect(response).to(
redirect_to(edit_tab_type_path(id: type.id, tab: :projects))
)
end
it "has no projects assigned" do
expect(Type.find_by(name: "My type").projects.count).to eq(0)
end
end
end
describe "POST move" do
@@ -0,0 +1,87 @@
# 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 "spec_helper"
RSpec.describe WorkPackageTypes::ProjectsTabController do
let(:project) { create(:project) }
let(:type) { create(:type_bug) }
before do
login_as user
end
context "without admin access" do
let(:user) { create :user }
describe "GET edit" do
before do
get :edit, params: { type_id: type.id }
end
it { expect(response).to have_http_status(:forbidden) }
end
end
context "with admin access" do
let(:user) { create :admin }
describe "GET edit" do
before do
get :edit, params: { type_id: type.id }
end
it { expect(response).to have_http_status(:ok) }
it { expect(response).to render_template "edit" }
end
describe "PUT update" do
let(:project_ids) { [project.id.to_s] }
let(:params) do
{
"type_id" => type.id,
"type" => { "project_ids" => project_ids.to_json }
}
end
before do
put :update, params:
end
it { expect(response).to redirect_to(edit_type_projects_path(type_id: type.id)) }
context "if the project id does not exist" do
let(:project_ids) { ["not_here"] }
it { expect(response).to have_http_status(:unprocessable_entity) }
end
end
end
end