diff --git a/app/models/type.rb b/app/models/type.rb index 0e6ab797ceb0..e7a509ea464b 100644 --- a/app/models/type.rb +++ b/app/models/type.rb @@ -62,17 +62,29 @@ def copy_from_type(source_type) join_table: "#{table_name_prefix}custom_fields_types#{table_name_suffix}", association_foreign_key: "custom_field_id" - acts_as_list + belongs_to :parent, class_name: "Type", optional: true + has_many :children, class_name: "Type", foreign_key: :parent_id, inverse_of: :parent, + dependent: :restrict_with_error + + acts_as_list scope: :parent_id validates :name, presence: true, - uniqueness: { case_sensitive: false }, + uniqueness: { scope: :parent_id, case_sensitive: false }, length: { maximum: 255 } + validate :parent_is_a_root + validate :not_own_parent + validate :cannot_have_children_when_child + validate :standard_type_stays_root + validate :parent_frozen_with_work_packages + scopes :milestone default_scope { order("position ASC") } + scope :roots, -> { where(parent_id: nil) } + scope :subtypes, -> { where.not(parent_id: nil) } scope :without_standard, -> { where(is_standard: false).order(:position) } scope :default, -> { where(is_default: true) } scope :visible, ->(user = User.current) { @@ -133,6 +145,14 @@ def enabled_in?(object) object.types.include?(self) end + def root + parent || self + end + + def family + [root, *root.children] + end + def replacement_pattern_defined_for?(attribute) enabled_patterns.key?(attribute) end @@ -153,4 +173,30 @@ def check_integrity true end + + def parent_is_a_root + return if parent.nil? + + errors.add(:parent, :must_be_a_root) if parent.parent_id.present? + end + + def not_own_parent + errors.add(:parent, :cannot_be_self) if parent_id.present? && parent_id == id + end + + def cannot_have_children_when_child + return if parent_id.blank? || new_record? + + errors.add(:parent, :cannot_have_children) if children.exists? + end + + def standard_type_stays_root + errors.add(:parent, :standard_type_must_be_root) if is_standard? && parent_id.present? + end + + def parent_frozen_with_work_packages + return unless parent_id_changed? && persisted? + + errors.add(:parent, :cannot_change_with_work_packages) if WorkPackage.exists?(type_id: id) + end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 55eabaed0a7b..af90a6b547fa 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2091,10 +2091,12 @@ en: type: description: "Default text for description" attribute_groups: "Form configuration" + children: "Sub-types" is_in_roadmap: "Displayed in roadmap by default" is_default: "Activated for new projects by default" is_milestone: "Is milestone" color: "Color" + parent: "Parent type" patterns: "Patterns" remote_identity: auth_source: "Auth Source" @@ -2634,6 +2636,12 @@ en: duplicate_group: "The group name '%{group}' is used more than once. Group names must be unique." query_invalid: "The embedded query '%{group}' is invalid: %{details}" group_without_name: "Group name can't be blank." + parent: + cannot_be_self: "can't be the type itself." + cannot_change_with_work_packages: "can't be changed because work packages of this type already exist." + cannot_have_children: "can't be set because this type already has sub-types." + must_be_a_root: "must itself be a top-level type. Types can only be nested one level deep." + standard_type_must_be_root: "can't be set for the standard type." patterns: invalid_tokens: "One or more attributes inside the field are not valid. Please, fix the attributes before saving." user: diff --git a/db/migrate/20260703151859_add_parent_to_types.rb b/db/migrate/20260703151859_add_parent_to_types.rb new file mode 100644 index 000000000000..c12d631c3be5 --- /dev/null +++ b/db/migrate/20260703151859_add_parent_to_types.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. +#++ + +class AddParentToTypes < ActiveRecord::Migration[8.1] + def up + add_reference :types, :parent, foreign_key: { to_table: :types }, null: true + + remove_index :types, name: "index_types_on_LOWER_name" + + add_index :types, "LOWER(name), parent_id", + unique: true, + nulls_not_distinct: true, + name: "index_types_on_LOWER_name_and_parent_id" + end + + def down + remove_index :types, name: "index_types_on_LOWER_name_and_parent_id" + add_index :types, "LOWER(name)", unique: true, name: "index_types_on_LOWER_name" + + remove_reference :types, :parent + end +end diff --git a/spec/models/type_spec.rb b/spec/models/type_spec.rb index 703747fa2f5e..d46bb6a2ad89 100644 --- a/spec/models/type_spec.rb +++ b/spec/models/type_spec.rb @@ -262,4 +262,122 @@ end end end + + describe "hierarchy" do + let!(:parent) { create(:type) } + let!(:child) { create(:type, parent:) } + + describe "associations" do + it "exposes parent and children" do + expect(child.parent).to eq(parent) + expect(parent.children).to contain_exactly(child) + end + end + + describe "scopes" do + it ".roots returns only top level types" do + expect(described_class.roots).to include(parent) + expect(described_class.roots).not_to include(child) + end + + it ".subtypes returns only nested types" do + expect(described_class.subtypes).to contain_exactly(child) + end + end + + describe "#root" do + it "returns the parent for a child" do + expect(child.root).to eq(parent) + end + + it "returns itself for a root" do + expect(parent.root).to eq(parent) + end + end + + describe "#family" do + it "returns the root followed by its children" do + expect(child.family).to eq([parent, child]) + expect(parent.family).to eq([parent, child]) + end + end + + describe "positioning" do + it "scopes position per parent so each group is numbered independently" do + parent_a = create(:type) + parent_b = create(:type) + a1 = create(:type, parent: parent_a, position: nil, name: "A1") + a2 = create(:type, parent: parent_a, position: nil, name: "A2") + b1 = create(:type, parent: parent_b, position: nil, name: "B1") + + expect([a1.position, a2.position]).to eq([1, 2]) + expect(b1.position).to eq(1) + end + end + + describe "validations" do + it "rejects nesting deeper than one level" do + grandchild = build(:type, parent: child) + + expect(grandchild).not_to be_valid + end + + it "rejects a type becoming its own parent" do + parent.parent = parent + + expect(parent).not_to be_valid + end + + it "rejects giving a parent to a type that already has children" do + other_root = create(:type) + parent.parent = other_root + + expect(parent).not_to be_valid + end + + it "rejects the standard type having a parent" do + standard = create(:type_standard) + standard.parent = parent + + expect(standard).not_to be_valid + end + + it "allows the same name under different parents" do + other_parent = create(:type) + create(:type, parent:, name: "Shared") + duplicate = build(:type, parent: other_parent, name: "Shared") + + expect(duplicate).to be_valid + end + + it "rejects a duplicate name under the same parent" do + create(:type, parent:, name: "Shared") + duplicate = build(:type, parent:, name: "Shared") + + expect(duplicate).not_to be_valid + end + + it "freezes parent_id once work packages exist" do + other_root = create(:type) + create(:work_package, type: child) + + child.parent = other_root + expect(child).not_to be_valid + end + + it "allows editing a type with work packages when the parent is unchanged" do + create(:work_package, type: child) + + child.name = "Renamed" + expect(child).to be_valid + end + end + + describe "deletion" do + it "is blocked while children exist" do + expect(parent.destroy).to be_falsey + expect(parent).to be_persisted + end + end + end end