Skip to content

Commit 3214ba6

Browse files
committed
[#71059] Add sprint goal CRUD to modal
Goal text field in the sprint dialog, with shared-sprint banners and permission-based field enable/disable. Create and update services persist goal as a `SprintGoal` row via `after_perform`. https://community.openproject.org/wp/71059
1 parent a492e51 commit 3214ba6

11 files changed

Lines changed: 273 additions & 24 deletions

File tree

modules/backlogs/app/components/backlogs/sprint_dialog_component.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ See COPYRIGHT and LICENSE files for more details.
3838
d.with_header(variant: :large)
3939

4040
d.with_body do
41-
render(Backlogs::SprintFormComponent.new(sprint: @sprint))
41+
render(Backlogs::SprintFormComponent.new(sprint: @sprint, project: @project))
4242
end
4343

4444
d.with_footer do

modules/backlogs/app/components/backlogs/sprint_dialog_component.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,15 @@ class SprintDialogComponent < ApplicationComponent
4141
STATE_DEFAULT = :create
4242
STATE_OPTIONS = [STATE_DEFAULT, :edit].freeze
4343

44-
attr_reader :sprint, :state
44+
attr_reader :sprint, :project, :state
4545

4646
delegate :create?, :edit?, to: :state
4747

48-
def initialize(sprint:, state: STATE_DEFAULT)
48+
def initialize(sprint:, project:, state: STATE_DEFAULT)
4949
super
5050

5151
@sprint = sprint
52+
@project = project
5253
@state = ActiveSupport::StringInquirer.new(fetch_or_fallback(STATE_OPTIONS, state, STATE_DEFAULT).to_s)
5354
end
5455

modules/backlogs/app/components/backlogs/sprint_form_component.html.erb

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,34 @@ See COPYRIGHT and LICENSE files for more details.
4444
render(Primer::Alpha::Banner.new(mb: 3, icon: :stop, scheme: :danger)) { base_errors.join("\n") }
4545
end
4646
end
47+
if shared_sprint?
48+
flex.with_row(mb: 3) do
49+
render(Primer::Alpha::Banner.new(
50+
scheme: banner_scheme,
51+
icon: banner_scheme == :warning ? :alert : :info
52+
)) { banner_text }
53+
end
54+
end
4755
flex.with_row(mb: 3) do
48-
render Backlogs::Sprints::DetailsForm.new(f)
56+
render Backlogs::Sprints::DetailsForm.new(f, disabled: !can_edit_sprint?)
4957
end
5058
flex.with_row(classes: "FormControl-horizontalGroup--sm-vertical") do
51-
render Backlogs::Sprints::DatesForm.new(f)
59+
render Backlogs::Sprints::DatesForm.new(f, disabled: !can_edit_sprint?)
60+
end
61+
if shared_sprint?
62+
flex.with_row(my: 3) do
63+
render(Primer::BaseComponent.new(tag: :hr, border_color: :muted))
64+
end
65+
end
66+
flex.with_row(mb: 3) do
67+
render(Primer::Alpha::TextField.new(
68+
name: "sprint[goal]",
69+
label: goal_label,
70+
value: goal_value,
71+
caption: goal_caption,
72+
disabled: !can_edit_goal?,
73+
full_width: true
74+
))
5275
end
5376
end
5477
end

modules/backlogs/app/components/backlogs/sprint_form_component.rb

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,59 @@ class SprintFormComponent < ApplicationComponent
3737

3838
FORM_ID = SprintDialogComponent::FORM_ID
3939

40-
attr_reader :sprint, :base_errors
40+
attr_reader :sprint, :project, :current_user, :base_errors
4141

42-
def initialize(sprint:, base_errors: nil)
42+
def initialize(sprint:, project:, current_user: User.current, base_errors: nil)
4343
super
4444

4545
@sprint = sprint
46+
@project = project
47+
@current_user = current_user
4648
@base_errors = base_errors
4749
end
4850

51+
def shared_sprint?
52+
sprint.persisted? && !sprint.owned_by?(project)
53+
end
54+
55+
def can_edit_sprint?
56+
return true unless shared_sprint?
57+
58+
current_user.allowed_in_project?(:create_sprints, sprint.project)
59+
end
60+
61+
def can_edit_goal?
62+
return true unless shared_sprint?
63+
64+
current_user.allowed_in_project?(:create_sprints, project)
65+
end
66+
67+
def banner_scheme
68+
can_edit_sprint? ? :default : :warning
69+
end
70+
71+
def banner_text
72+
if can_edit_sprint?
73+
I18n.t("backlogs.sprint_form.shared_sprint_info_banner")
74+
else
75+
I18n.t("backlogs.sprint_form.shared_sprint_warning_banner")
76+
end
77+
end
78+
79+
def goal_label
80+
label = Sprint.human_attribute_name(:goal)
81+
label += " #{I18n.t('backlogs.sprint_form.goal_for_this_project_suffix')}" if shared_sprint?
82+
label
83+
end
84+
85+
def goal_caption
86+
I18n.t("backlogs.sprint_form.goal_caption") if shared_sprint?
87+
end
88+
89+
def goal_value
90+
sprint.goal_for(project)&.goal
91+
end
92+
4993
private
5094

5195
def http_verb

modules/backlogs/app/controllers/backlogs/sprints_controller.rb

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,13 @@ def new_dialog
5252
contract_class: ::EmptyContract
5353
).call(attributes: converted_sprint_params)
5454

55-
respond_with_dialog Backlogs::SprintDialogComponent.new(sprint: call.result)
55+
respond_with_dialog Backlogs::SprintDialogComponent.new(sprint: call.result, project: @project)
5656
end
5757

5858
def edit_dialog
5959
@sprint = Sprint.for_project(@project).visible.find(params[:sprint_id])
6060

61-
respond_with_dialog Backlogs::SprintDialogComponent.new(sprint: @sprint, state: :edit)
61+
respond_with_dialog Backlogs::SprintDialogComponent.new(sprint: @sprint, project: @project, state: :edit)
6262
end
6363

6464
def refresh_form
@@ -71,15 +71,15 @@ def refresh_form
7171
contract_class: ::EmptyContract
7272
).call(attributes: converted_sprint_params)
7373

74-
update_via_turbo_stream(component: Backlogs::SprintFormComponent.new(sprint: call.result))
74+
update_via_turbo_stream(component: Backlogs::SprintFormComponent.new(sprint: call.result, project: @project))
7575

7676
respond_with_turbo_streams
7777
end
7878

7979
def create # rubocop:disable Metrics/AbcSize
8080
call = ::Sprints::CreateService
8181
.new(user: current_user)
82-
.call(attributes: converted_sprint_params)
82+
.call(attributes: converted_sprint_params, goal: goal_param)
8383

8484
if call.success?
8585
flash[:notice] = I18n.t(:notice_successful_create)
@@ -93,7 +93,7 @@ def create # rubocop:disable Metrics/AbcSize
9393
def update
9494
call = ::Sprints::UpdateService
9595
.new(user: current_user, model: @sprint)
96-
.call(attributes: sprint_params[:sprint])
96+
.call(attributes: sprint_update_params, goal: goal_param, goal_project: @project)
9797

9898
if call.success?
9999
render_success_flash_message_via_turbo_stream(message: I18n.t(:notice_successful_update))
@@ -145,6 +145,7 @@ def update_sprint_form_component_via_turbo_stream(sprint:, base_errors: nil)
145145
update_via_turbo_stream(
146146
component: Backlogs::SprintFormComponent.new(
147147
sprint:,
148+
project: @project,
148149
base_errors:
149150
),
150151
status: :bad_request
@@ -169,15 +170,23 @@ def load_sprint_and_project
169170
end
170171

171172
def sprint_params
172-
params.permit(sprint: %i[name start_date finish_date])
173+
params.permit(sprint: %i[name start_date finish_date goal])
173174
end
174175

175176
def edit_sprint_params
176-
params.permit(sprint: %i[id name start_date finish_date])
177+
params.permit(sprint: %i[id name start_date finish_date goal])
178+
end
179+
180+
def goal_param
181+
sprint_params.dig(:sprint, :goal)
182+
end
183+
184+
def sprint_update_params
185+
sprint_params[:sprint].except(:goal)
177186
end
178187

179188
def converted_sprint_params
180-
converted_params = sprint_params[:sprint].to_h
189+
converted_params = sprint_params[:sprint].to_h.except(:goal)
181190
converted_params[:project] = @project
182191

183192
converted_params

modules/backlogs/app/forms/backlogs/sprints/dates_form.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,15 @@
3131
module Backlogs
3232
module Sprints
3333
class DatesForm < ApplicationForm
34+
attr_reader :disabled
35+
3436
delegate :active?, to: :model
3537

38+
def initialize(disabled: false)
39+
@disabled = disabled
40+
super()
41+
end
42+
3643
form do |f|
3744
f.group(layout: :horizontal) do |dates|
3845
dates.text_field(
@@ -41,6 +48,7 @@ class DatesForm < ApplicationForm
4148
label: attribute_name(:start_date),
4249
placeholder: attribute_name(:start_date),
4350
required: active?,
51+
disabled:,
4452
input_width: :small,
4553
data: {
4654
action: "change->refresh-on-form-changes#triggerTurboStream"
@@ -52,6 +60,7 @@ class DatesForm < ApplicationForm
5260
label: attribute_name(:finish_date),
5361
placeholder: attribute_name(:finish_date),
5462
required: active?,
63+
disabled:,
5564
input_width: :small,
5665
data: {
5766
action: "change->refresh-on-form-changes#triggerTurboStream"
@@ -62,6 +71,7 @@ class DatesForm < ApplicationForm
6271
label: attribute_name(:duration),
6372
input_width: :xsmall,
6473
readonly: true,
74+
disabled:,
6575
value: display_duration
6676
)
6777
end

modules/backlogs/app/forms/backlogs/sprints/details_form.rb

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@
3131
module Backlogs
3232
module Sprints
3333
class DetailsForm < ApplicationForm
34+
attr_reader :disabled
35+
36+
def initialize(disabled: false)
37+
@disabled = disabled
38+
super()
39+
end
40+
3441
form do |f|
3542
f.hidden(name: :id)
3643

@@ -39,16 +46,9 @@ class DetailsForm < ApplicationForm
3946
name: :name,
4047
required: true,
4148
autofocus: true,
49+
disabled:,
4250
w: :full
4351
)
44-
45-
# f.text_area(
46-
# label: attribute_name(:goal),
47-
# name: :goal,
48-
# required: false,
49-
# w: :full,
50-
# rows: 3
51-
# )
5252
end
5353
end
5454
end

modules/backlogs/app/services/sprints/create_service.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,26 @@ class Sprints::CreateService < BaseServices::Create
3232
def instance_class
3333
Sprint
3434
end
35+
36+
protected
37+
38+
def set_attributes_params(params)
39+
super.except(:goal, :goal_project)
40+
end
41+
42+
def after_perform(service_call)
43+
super.tap do
44+
persist_goal(service_call) if service_call.success?
45+
end
46+
end
47+
48+
private
49+
50+
def persist_goal(service_call)
51+
goal_text = params[:goal]
52+
return if goal_text.nil?
53+
54+
sprint = service_call.result
55+
SprintGoal.create!(sprint:, project: sprint.project, goal: goal_text) if goal_text.present?
56+
end
3557
end

modules/backlogs/app/services/sprints/update_service.rb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,36 @@ class Sprints::UpdateService < BaseServices::Update
3232
def instance_class
3333
Sprint
3434
end
35+
36+
protected
37+
38+
def set_attributes_params(params)
39+
super.except(:goal, :goal_project)
40+
end
41+
42+
def after_perform(service_call)
43+
super.tap do
44+
persist_goal(service_call) if service_call.success?
45+
end
46+
end
47+
48+
private
49+
50+
def persist_goal(service_call)
51+
goal_text = params[:goal]
52+
return if goal_text.nil?
53+
54+
sprint = service_call.result
55+
project = params[:goal_project] || sprint.project
56+
57+
return unless user.allowed_in_project?(:create_sprints, project)
58+
59+
sprint_goal = SprintGoal.find_or_initialize_by(sprint:, project:)
60+
61+
if goal_text.present?
62+
sprint_goal.update!(goal: goal_text)
63+
elsif sprint_goal.persisted?
64+
sprint_goal.destroy!
65+
end
66+
end
3567
end

modules/backlogs/config/locales/en.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ en:
9696
sprint: "Sprint"
9797

9898
backlogs:
99+
sprint_form:
100+
shared_sprint_info_banner: "This is a shared sprint. Modifications will be reflected in all projects using it."
101+
shared_sprint_warning_banner: "This is a shared sprint. You do not have the necessary permissions to edit it."
102+
goal_for_this_project_suffix: "(for this project)"
103+
goal_caption: "The sprint goal is unique to this project and is not shared with other projects also using this sprint."
104+
99105
caption_sprints_default_fold_state: "Sprints will not be expanded by default when viewing the 'Backlog and sprints' page. Each one has to be manually expanded."
100106
definition_of_done: "Definition of Done"
101107
definition_of_done_caption: "Work packages with these statuses are treated as completed in backlog views and reporting."

0 commit comments

Comments
 (0)