Skip to content

Commit fe85404

Browse files
authored
Merge pull request #24225 from opf/implementation/fnd-130-runtime-resolution-of-linked-form-configurations
Runtime resolution of linked form configurations
2 parents 34a3891 + 5fd6416 commit fe85404

7 files changed

Lines changed: 183 additions & 75 deletions

File tree

app/components/work_packages/exports/generate/modal_dialog_component.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def templates_default
5959
end
6060

6161
def templates_options
62-
work_package.type.pdf_export_templates.list_enabled
62+
work_package.type.effective_pdf_export_templates.list_enabled
6363
end
6464

6565
def hyphenation_default

app/models/type.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ def replacement_pattern_defined_for?(attribute)
209209
end
210210

211211
def enabled_patterns
212-
patterns.all_enabled
212+
effective_patterns.all_enabled
213213
end
214214

215215
def pdf_export_templates

app/models/type/configuration_link.rb

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,23 +30,25 @@
3030

3131
# A single reuse link: a type borrows one configuration aspect from a source type.
3232
# Absence of a row for (type, aspect) means the type owns that aspect (Independent).
33-
class Type::ConfigurationLink < ApplicationRecord
34-
ASPECTS = [
35-
PDF_EXPORT = "pdf_export",
36-
PATTERNS = "patterns"
37-
].freeze
33+
class Type
34+
class ConfigurationLink < ApplicationRecord
35+
ASPECTS = [
36+
PDF_EXPORT = "pdf_export",
37+
PATTERNS = "patterns"
38+
].freeze
3839

39-
belongs_to :type, optional: false
40-
belongs_to :source, class_name: "Type", optional: false
40+
belongs_to :type, optional: false
41+
belongs_to :source, class_name: "Type", optional: false
4142

42-
enum :aspect, ASPECTS.index_with(&:itself), validate: true
43+
enum :aspect, ASPECTS.index_with(&:itself), validate: true
4344

44-
validates :type_id, uniqueness: { scope: :aspect }
45-
validate :source_differs_from_type
45+
validates :type_id, uniqueness: { scope: :aspect }
46+
validate :source_differs_from_type
4647

47-
private
48+
private
4849

49-
def source_differs_from_type
50-
errors.add(:source, :must_differ_from_type) if source_id.present? && source_id == type_id
50+
def source_differs_from_type
51+
errors.add(:source, :must_differ_from_type) if source_id.present? && source_id == type_id
52+
end
5153
end
5254
end

app/models/type/configuration_linkable.rb

Lines changed: 65 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -32,76 +32,83 @@
3232
# source type. Mode is derived from link presence: a link means Linked, its
3333
# absence means Independent. This is the shared seam FND-101/102 extend with
3434
# further aspects, and the deferred resolution/copy/cycle work builds on top of.
35-
module Type::ConfigurationLinkable
36-
extend ActiveSupport::Concern
35+
class Type
36+
module ConfigurationLinkable
37+
extend ActiveSupport::Concern
3738

38-
included do
39-
has_many :configuration_links,
40-
class_name: "Type::ConfigurationLink",
41-
dependent: :destroy
42-
has_many :dependent_configuration_links,
43-
class_name: "Type::ConfigurationLink",
44-
foreign_key: :source_id,
45-
inverse_of: :source,
46-
dependent: :restrict_with_error
39+
included do
40+
has_many :configuration_links,
41+
class_name: "Type::ConfigurationLink",
42+
dependent: :destroy
43+
has_many :dependent_configuration_links,
44+
class_name: "Type::ConfigurationLink",
45+
foreign_key: :source_id,
46+
inverse_of: :source,
47+
dependent: :restrict_with_error
4748

48-
# A sub-type defaults to Linked-to-parent for every reusable aspect.
49-
after_create :seed_default_configuration_links, if: :subtype?
50-
end
49+
# A sub-type defaults to Linked-to-parent for every reusable aspect.
50+
after_create :seed_default_configuration_links, if: :subtype?
51+
end
5152

52-
def linked?(aspect)
53-
configuration_links.exists?(aspect:)
54-
end
53+
def linked?(aspect)
54+
configuration_links.exists?(aspect:)
55+
end
5556

56-
def source_for(aspect)
57-
configuration_links.find_by(aspect:)&.source
58-
end
57+
def source_for(aspect)
58+
configuration_links.find_by(aspect:)&.source
59+
end
5960

60-
def link!(aspect, source:)
61-
configuration_links.find_or_initialize_by(aspect:).update!(source:)
62-
end
61+
def link!(aspect, source:)
62+
configuration_links.find_or_initialize_by(aspect:).update!(source:)
63+
end
6364

64-
# Switch an aspect to Independent. When a source is given, its resolved
65-
# configuration is copied onto this type once (adopt) before the link is severed.
66-
def make_independent!(aspect, source: nil)
67-
copy_configuration_from(source, aspect) if source && source != self
68-
configuration_links.where(aspect:).destroy_all
69-
end
65+
# Switch an aspect to Independent. When a source is given, its resolved
66+
# configuration is copied onto this type once (adopt) before the link is severed.
67+
def make_independent!(aspect, source: nil)
68+
copy_configuration_from(source, aspect) if source && source != self
69+
configuration_links.where(aspect:).destroy_all
70+
end
7071

71-
# Walks the link chain to the type that actually owns the aspect (Independent).
72-
# The visited-set guard keeps it terminating even before transitive cycle
73-
# prevention lands.
74-
def effective_source_for(aspect)
75-
node = self
76-
seen = Set.new
77-
node = node.source_for(aspect) while node.linked?(aspect) && seen.add?(node.id)
78-
node
79-
end
72+
# Walks the link chain to the type that actually owns the aspect (Independent).
73+
# The visited-set guard keeps it terminating even before transitive cycle
74+
# prevention lands.
75+
#
76+
# Guarded by the subtypes feature flag: with the flag off, links are ignored
77+
# and every type resolves to its own stored configuration.
78+
def effective_source_for(aspect)
79+
return self unless OpenProject::FeatureDecisions.subtypes_active?
8080

81-
def effective_patterns
82-
effective_source_for(Type::ConfigurationLink::PATTERNS).patterns
83-
end
81+
node = self
82+
seen = Set.new
83+
node = node.source_for(aspect) while node.linked?(aspect) && seen.add?(node.id)
84+
node
85+
end
8486

85-
def effective_pdf_export_templates
86-
effective_source_for(Type::ConfigurationLink::PDF_EXPORT).pdf_export_templates
87-
end
87+
def effective_patterns
88+
effective_source_for(Type::ConfigurationLink::PATTERNS).patterns
89+
end
8890

89-
# One-time adoption: copy the source's resolved configuration for one aspect
90-
# onto this type. Used when switching to Independent from a chosen source.
91-
# deep_dup keeps the copy from aliasing the source's stored value.
92-
def copy_configuration_from(source, aspect)
93-
owner = source.effective_source_for(aspect)
94-
case aspect
95-
when Type::ConfigurationLink::PATTERNS
96-
update!(patterns: owner.patterns.deep_dup)
97-
when Type::ConfigurationLink::PDF_EXPORT
98-
update!(pdf_export_templates_config: owner.pdf_export_templates_config.deep_dup)
91+
def effective_pdf_export_templates
92+
effective_source_for(Type::ConfigurationLink::PDF_EXPORT).pdf_export_templates
93+
end
94+
95+
# One-time adoption: copy the source's resolved configuration for one aspect
96+
# onto this type. Used when switching to Independent from a chosen source.
97+
# deep_dup keeps the copy from aliasing the source's stored value.
98+
def copy_configuration_from(source, aspect)
99+
owner = source.effective_source_for(aspect)
100+
case aspect
101+
when Type::ConfigurationLink::PATTERNS
102+
update!(patterns: owner.patterns.deep_dup)
103+
when Type::ConfigurationLink::PDF_EXPORT
104+
update!(pdf_export_templates_config: owner.pdf_export_templates_config.deep_dup)
105+
end
99106
end
100-
end
101107

102-
private
108+
private
103109

104-
def seed_default_configuration_links
105-
Type::ConfigurationLink::ASPECTS.each { |aspect| link!(aspect, source: parent) }
110+
def seed_default_configuration_links
111+
Type::ConfigurationLink::ASPECTS.each { |aspect| link!(aspect, source: parent) }
112+
end
106113
end
107114
end
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# frozen_string_literal: true
2+
3+
#-- copyright
4+
# OpenProject is an open source project management software.
5+
# Copyright (C) the OpenProject GmbH
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License version 3.
9+
#
10+
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
11+
# Copyright (C) 2006-2013 Jean-Philippe Lang
12+
# Copyright (C) 2010-2013 the ChiliProject Team
13+
#
14+
# This program is free software; you can redistribute it and/or
15+
# modify it under the terms of the GNU General Public License
16+
# as published by the Free Software Foundation; either version 2
17+
# of the License, or (at your option) any later version.
18+
#
19+
# This program is distributed in the hope that it will be useful,
20+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
21+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22+
# GNU General Public License for more details.
23+
#
24+
# You should have received a copy of the GNU General Public License
25+
# along with this program; if not, write to the Free Software
26+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
27+
#
28+
# See COPYRIGHT and LICENSE files for more details.
29+
#++
30+
31+
require "spec_helper"
32+
33+
RSpec.describe WorkPackages::Exports::Generate::ModalDialogComponent, type: :component do
34+
subject(:component) { described_class.new(work_package:, params: {}) }
35+
36+
let(:type) { create(:type) }
37+
let(:work_package) { build_stubbed(:work_package, type:) }
38+
39+
describe "#templates_options", with_flag: { subtypes: true } do
40+
it "lists the enabled templates of the type the PDF config is linked to" do
41+
source = create(:type)
42+
source.pdf_export_templates.disable_all
43+
source.save!
44+
type.link!(Type::ConfigurationLink::PDF_EXPORT, source:)
45+
46+
expect(component.templates_options).to be_empty
47+
end
48+
end
49+
end

spec/models/type/configuration_linkable_spec.rb

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@
123123
end
124124
end
125125

126-
describe "#effective_source_for" do
126+
describe "#effective_source_for", with_flag: { subtypes: true } do
127127
it "returns itself when Independent" do
128128
expect(type.effective_source_for(aspect)).to eq(type)
129129
end
@@ -146,7 +146,7 @@
146146
end
147147
end
148148

149-
describe "effective configuration" do
149+
describe "effective configuration", with_flag: { subtypes: true } do
150150
let(:owner) { create(:type, patterns: { subject: { blueprint: "X {{id}}", enabled: true } }) }
151151

152152
it "resolves patterns from the linked owner" do
@@ -160,6 +160,33 @@
160160
end
161161
end
162162

163+
describe "#enabled_patterns and #replacement_pattern_defined_for?", with_flag: { subtypes: true } do
164+
let(:owner) { create(:type, patterns: { subject: { blueprint: "X {{id}}", enabled: true } }) }
165+
166+
it "resolves the subject pattern from the linked owner" do
167+
type.link!(Type::ConfigurationLink::PATTERNS, source: owner)
168+
169+
expect(type.enabled_patterns.keys).to include(:subject)
170+
expect(type).to be_replacement_pattern_defined_for(:subject)
171+
end
172+
173+
it "reports no subject pattern when Independent and none is set" do
174+
expect(type).not_to be_replacement_pattern_defined_for(:subject)
175+
end
176+
end
177+
178+
describe "feature flag gating", with_flag: { subtypes: false } do
179+
let(:owner) { create(:type, patterns: { subject: { blueprint: "X {{id}}", enabled: true } }) }
180+
181+
before { type.link!(Type::ConfigurationLink::PATTERNS, source: owner) }
182+
183+
it "ignores links and resolves to the type's own configuration" do
184+
expect(type.effective_source_for(Type::ConfigurationLink::PATTERNS)).to eq(type)
185+
expect(type.effective_patterns).to eq(type.patterns)
186+
expect(type).not_to be_replacement_pattern_defined_for(:subject)
187+
end
188+
end
189+
163190
describe "#copy_configuration_from" do
164191
it "copies the source's subject patterns onto the type" do
165192
source = create(:type, patterns: { subject: { blueprint: "Copied {{id}}", enabled: true } })

spec/services/work_packages/update_service_integration_spec.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1801,6 +1801,29 @@
18011801
end
18021802
end
18031803

1804+
context "with a type whose subject configuration is linked to a source type",
1805+
with_flag: { subtypes: true } do
1806+
shared_let(:linked_type) do
1807+
create(:type, name: "Linked").tap do |t|
1808+
t.link!(Type::ConfigurationLink::PATTERNS, source: autosubject_type)
1809+
project.types << t
1810+
end
1811+
end
1812+
1813+
shared_let(:work_package, reload: true) { create(:work_package, type: linked_type, project:) }
1814+
1815+
let(:attributes) { { description: "new description" } }
1816+
1817+
it "generates the subject from the linked source type's pattern" do
1818+
expect(subject).to be_success
1819+
1820+
expect(work_package.reload).to have_attributes(
1821+
description: "new description",
1822+
subject: "##{work_package.id} by #{user.name} - #{default_status.name}"
1823+
)
1824+
end
1825+
end
1826+
18041827
describe "replacing the attachments" do
18051828
let!(:old_attachment) do
18061829
create(:attachment, container: work_package)

0 commit comments

Comments
 (0)