Skip to content

Commit 20e7e57

Browse files
committed
Judge a variant switch in a contract, not a form object. FND-109
Every other Primer form in the codebase binds an ActiveRecord record or no model at all, so the form object was ours alone to maintain. The dialog now submits straight to the service like the add dialog beside it, and the contract is the only place a switch is judged. A refusal reads as a flash rather than a message under the select.
1 parent 7be9eea commit 20e7e57

12 files changed

Lines changed: 105 additions & 231 deletions

File tree

app/components/projects/settings/work_packages/types/switch_dialog_component.html.erb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,7 @@ See COPYRIGHT and LICENSE files for more details.
3636
) do |dialog| %>
3737
<% dialog.with_header(variant: :large) %>
3838

39-
<%= render(Projects::Settings::WorkPackages::Types::SwitchFormComponent.new(switch: @switch)) %>
39+
<%= render(
40+
Projects::Settings::WorkPackages::Types::SwitchFormComponent.new(project:, source:)
41+
) %>
4042
<% end %>

app/components/projects/settings/work_packages/types/switch_dialog_component.rb

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,16 @@ class SwitchDialogComponent < ApplicationComponent
3939

4040
DIALOG_ID = "project-types-switch-dialog"
4141

42-
def initialize(switch:)
42+
def initialize(project:, source:)
4343
super()
4444

45-
@switch = switch
45+
@project = project
46+
@source = source
4647
end
4748

48-
delegate :source, to: :@switch, private: true
49+
private
50+
51+
attr_reader :project, :source
4952
end
5053
end
5154
end

app/components/projects/settings/work_packages/types/switch_form_component.html.erb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ See COPYRIGHT and LICENSE files for more details.
2828
++#%>
2929

3030
<%= component_wrapper do %>
31-
<%= primer_form_with(model: @switch, scope: :switch, url: submit_path) do |form| %>
31+
<%= primer_form_with(url: switch_path, method: :post) do |form| %>
3232
<%= render(Primer::Alpha::Dialog::Body.new) do %>
3333
<%= flex_layout(direction: :column) do |body| %>
3434
<% body.with_row(mb: 3) do %>
@@ -42,7 +42,11 @@ See COPYRIGHT and LICENSE files for more details.
4242
<% end %>
4343
<% end %>
4444
<% body.with_row do %>
45-
<%= render(Projects::Settings::WorkPackages::Types::SwitchForm.new(form, switch: @switch)) %>
45+
<%= render(
46+
Projects::Settings::WorkPackages::Types::SwitchForm.new(
47+
form, targets: available_targets, selected:, validation_message:
48+
)
49+
) %>
4650
<% end %>
4751
<% end %>
4852
<% end %>

app/components/projects/settings/work_packages/types/switch_form_component.rb

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,27 +32,34 @@ module Projects
3232
module Settings
3333
module WorkPackages
3434
module Types
35-
# The switch dialog's contents. Separate from the dialog so a validation
36-
# failure can replace it: replacing the dialog component would swap out
37-
# the <dialog> element and close it.
35+
# The switch dialog's form. Separate from the dialog so a refused switch can replace
36+
# it: replacing the dialog component would swap out the <dialog> element and close it.
3837
class SwitchFormComponent < ApplicationComponent
3938
include OpPrimer::ComponentHelpers
4039
include OpTurbo::Streamable
4140

42-
def initialize(switch:)
41+
def initialize(project:, source:, selected: source, validation_message: nil)
4342
super()
4443

45-
@switch = switch
44+
@project = project
45+
@source = source
46+
@selected = selected
47+
@validation_message = validation_message
4648
end
4749

4850
private
4951

50-
delegate :project, :source, to: :@switch
52+
attr_reader :project, :source, :selected, :validation_message
5153

52-
def submit_path
54+
def switch_path
5355
project_settings_work_packages_type_switch_path(project, source)
5456
end
5557

58+
def available_targets
59+
source.family
60+
end
61+
62+
# Constant lookup in a compiled template does not walk the enclosing modules.
5663
def dialog_id
5764
SwitchDialogComponent::DIALOG_ID
5865
end

app/models/projects/types/switch.rb renamed to app/contracts/projects/types/switch_variant_contract.rb

Lines changed: 21 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -30,48 +30,31 @@
3030

3131
module Projects
3232
module Types
33-
# Which member of a type family a project should use. Backs the switch
34-
# dialog: the form binds to it, and it owns the preview of the switch it
35-
# describes.
36-
class Switch
37-
include ActiveModel::Model
38-
39-
attr_accessor :project, :source
40-
attr_writer :target_id
41-
42-
validate :target_selectable
43-
44-
def target_id
45-
@target_id.presence&.to_i
46-
end
47-
48-
def target
49-
return @target if defined?(@target)
50-
51-
@target = target_id && ::Type.find_by(id: target_id)
52-
end
53-
54-
def available_targets
55-
source.family
56-
end
57-
58-
# The dialog opens on the member the project uses now, so applying without
59-
# choosing anything is a visible no-op rather than an empty field.
60-
def selected_target
61-
target || source
33+
# Whether a project may move from one member of a type family to another.
34+
#
35+
# The pair being switched arrives through the contract options rather than off the model:
36+
# a switch changes which member the project resolves to, which is a row in project_types
37+
# rather than an attribute of the project the contract validates.
38+
class SwitchVariantContract < ManageTypesContract
39+
validate :validate_target_selectable
40+
41+
protected
42+
43+
def validate_target_selectable
44+
if target.nil?
45+
errors.add(:types, :switch_target_blank)
46+
elsif target == source
47+
errors.add(:types, :switch_target_identical)
48+
elsif source.root_id != target.root_id
49+
errors.add(:types, :switch_target_not_in_family)
50+
end
6251
end
6352

6453
private
6554

66-
def target_selectable
67-
if target_id.blank?
68-
errors.add(:target_id, :blank)
69-
elsif available_targets.exclude?(target)
70-
errors.add(:target_id, :not_in_family)
71-
elsif target == source
72-
errors.add(:target_id, :unchanged)
73-
end
74-
end
55+
def source = options[:source]
56+
57+
def target = options[:target]
7558
end
7659
end
7760
end

app/controllers/projects/settings/work_packages/types/switches_controller.rb

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -31,30 +31,26 @@
3131
class Projects::Settings::WorkPackages::Types::SwitchesController < Projects::SettingsController
3232
include WorkPackageTypes::TypeVariantsFeature
3333
include OpTurbo::ComponentStream
34-
include FlashMessagesOutputSafetyHelper
3534

3635
menu_item :settings_work_packages
3736

3837
before_action :require_type_variants_feature
3938
before_action :load_source
4039

4140
def new
42-
respond_with_dialog Projects::Settings::WorkPackages::Types::SwitchDialogComponent.new(switch: build_switch)
41+
respond_with_dialog Projects::Settings::WorkPackages::Types::SwitchDialogComponent
42+
.new(project: @project, source: @source)
4343
end
4444

4545
def create
46-
switch = build_switch
47-
48-
return render_invalid(switch) unless switch.valid?
46+
target = ::Type.find_by(id: params[:target_id])
4947

5048
result = ::Projects::Types::SwitchVariantService
5149
.new(user: current_user, model: @project)
52-
.call(source: switch.source, target: switch.target)
50+
.call(source: @source, target:)
5351

54-
result.on_success { on_switched(switch) }
55-
result.on_failure do
56-
render_error_flash_message_via_turbo_stream(message: join_flash_messages(result.errors.full_messages))
57-
end
52+
result.on_success { on_switched(target) }
53+
result.on_failure { on_refused(target, result) }
5854

5955
respond_to_with_turbo_streams(status: result)
6056
end
@@ -75,26 +71,28 @@ def load_source
7571
respond_to_with_turbo_streams(status: :unprocessable_entity)
7672
end
7773

78-
def build_switch
79-
::Projects::Types::Switch.new(project: @project, source: @source, target_id: params.dig(:switch, :target_id))
80-
end
74+
# Repainted with the refusal under the select, so the choice can be corrected where it was
75+
# made. A refusal that belongs to no field is the contract turning away a user the permission
76+
# map already turned away, and has nowhere to show.
77+
def on_refused(target, result)
78+
message = result.errors.messages_for(:types).first
79+
return if message.blank?
8180

82-
def render_invalid(switch)
8381
update_via_turbo_stream(
84-
component: Projects::Settings::WorkPackages::Types::SwitchFormComponent.new(switch:)
82+
component: Projects::Settings::WorkPackages::Types::SwitchFormComponent.new(
83+
project: @project, source: @source, selected: target || @source, validation_message: message
84+
)
8585
)
86-
87-
respond_to_with_turbo_streams(status: :unprocessable_entity)
8886
end
8987

9088
# Reload so the repainted list no longer sees the association's cached types.
91-
def on_switched(switch)
89+
def on_switched(target)
9290
close_dialog_via_turbo_stream("##{Projects::Settings::WorkPackages::Types::SwitchDialogComponent::DIALOG_ID}")
9391
replace_via_turbo_stream(
9492
component: Projects::Settings::WorkPackages::Types::ListComponent.new(project: @project.reload)
9593
)
9694
render_success_flash_message_via_turbo_stream(
97-
message: t("projects.settings.types.switch_dialog.success", type: switch.target.composite_name)
95+
message: t("projects.settings.types.switch_dialog.success", type: target.composite_name)
9896
)
9997
end
10098
end

app/forms/projects/settings/work_packages/types/switch_form.rb

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,12 @@ module Settings
3333
module WorkPackages
3434
module Types
3535
class SwitchForm < ApplicationForm
36-
def initialize(switch:)
36+
def initialize(targets:, selected:, validation_message: nil)
3737
super()
3838

39-
@switch = switch
39+
@targets = targets
40+
@selected = selected
41+
@validation_message = validation_message
4042
end
4143

4244
form do |switch_form|
@@ -45,12 +47,13 @@ def initialize(switch:)
4547
label: I18n.t("projects.settings.types.switch_dialog.target_label"),
4648
include_blank: false,
4749
input_width: :medium,
50+
validation_message: @validation_message,
4851
data: { test_selector: "project-types-switch-select" }
4952
) do |list|
5053
# Composite rather than own names: repeating the family on every
5154
# option is what makes it evident that nothing outside it is on offer.
52-
@switch.available_targets.each do |target|
53-
list.option(value: target.id, label: target.composite_name, selected: target == @switch.selected_target)
55+
@targets.each do |target|
56+
list.option(value: target.id, label: target.composite_name, selected: target == @selected)
5457
end
5558
end
5659
end

app/services/projects/types/switch_variant_service.rb

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,24 @@ module Types
3535
# project's work packages are untouched: they store the root either way, so the switch is
3636
# a change of which configuration the project resolves to, not a retype.
3737
class SwitchVariantService < BaseService
38+
def initialize(user:, model:, contract_class: SwitchVariantContract)
39+
super
40+
end
41+
3842
private
3943

44+
# The pair is what the contract judges, and it only arrives with the call, so the
45+
# options cannot be handed over at construction time like a contract class can.
46+
def before_perform(service_call)
47+
self.contract_options = params.slice(:source, :target)
48+
49+
service_call
50+
end
51+
4052
def persist(service_call)
41-
source = params[:source]
42-
target = params[:target]
53+
switch(params[:target])
4354

44-
if source == target
45-
failure(:switch_target_identical)
46-
elsif source.root_id != target.root_id
47-
failure(:switch_target_not_in_family)
48-
else
49-
switch(target)
50-
service_call
51-
end
55+
service_call
5256
end
5357

5458
def switch(target)

config/locales/en.yml

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -84,15 +84,6 @@ en:
8484
attributes:
8585
projects/copy_options:
8686
dependencies: "Dependencies"
87-
projects/types/switch:
88-
target_id: "Variant"
89-
errors:
90-
models:
91-
projects/types/switch:
92-
attributes:
93-
target_id:
94-
not_in_family: "must belong to the same type family"
95-
unchanged: "must be different from the one the project uses now"
9687

9788
activerecord:
9889
attributes:
@@ -718,6 +709,7 @@ en:
718709
cannot_assign_variant_and_parent: "Cannot assign a variant and its parent "
719710
cannot_assign_variants_yet: "Variants cannot be enabled on a project yet. Enable the feature flag to try out variants"
720711
in_use_by_work_packages: "still in use by work packages: %{types}"
712+
switch_target_blank: "Select the variant to switch to"
721713
switch_target_identical: "The target type must be different from the type the project uses now"
722714
switch_target_not_in_family: "Can only switch to another type of the same family"
723715
cannot_be_assigned_to_artifact_work_package: "The chosen user is not allowed to be assigned to work packages."

spec/features/projects/settings/work_package_types_spec.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,15 @@
130130
end
131131
end
132132

133+
# Reported under the select rather than as a flash, so the choice can be corrected where it
134+
# was made.
133135
it "refuses to apply the variant the project already uses" do
134136
settings_page.open_switch_dialog(design)
135137
settings_page.apply_switch
136138

137139
within(settings_page.switch_dialog) do
138-
expect(page).to have_text("must be different from the one the project uses now")
140+
expect(page).to have_text("The target type must be different from the type the project uses now")
141+
expect(page).to have_select("Variant", selected: "Epic: Design")
139142
end
140143
expect(project.reload.project_types.find_by(type: epic).variant).to eq(design)
141144
end

0 commit comments

Comments
 (0)