Merge pull request #23072 from opf/feature/74147-bring-sprint-sharing-safe-to-corporate-plan

Feature/74147 bring sprint sharing safe to corporate plan
This commit is contained in:
Jens Ulferts
2026-05-20 22:30:53 +02:00
committed by GitHub
14 changed files with 292 additions and 86 deletions
+1 -1
View File
@@ -209,7 +209,7 @@ gem "aws-sdk-core", "~> 3.244"
# File upload via fog + screenshots on travis
gem "aws-sdk-s3", "~> 1.217"
gem "openproject-token", "~> 8.8.2"
gem "openproject-token", "~> 8.9.0"
gem "plaintext", "~> 0.3.7"
+3 -3
View File
@@ -914,7 +914,7 @@ GEM
activesupport (>= 7.2.0)
openproject-octicons (>= 19.34.0)
view_component (>= 3.1, < 5.0)
openproject-token (8.8.2)
openproject-token (8.9.0)
activemodel
openssl (4.0.2)
openssl-signature_algorithm (1.3.0)
@@ -1694,7 +1694,7 @@ DEPENDENCIES
openproject-resource_management!
openproject-storages!
openproject-team_planner!
openproject-token (~> 8.8.2)
openproject-token (~> 8.9.0)
openproject-two_factor_authentication!
openproject-webhooks!
openproject-wikis!
@@ -2075,7 +2075,7 @@ CHECKSUMS
openproject-resource_management (1.0.0)
openproject-storages (1.0.0)
openproject-team_planner (1.0.0)
openproject-token (8.8.2) sha256=081cbff7269d92a82fa1d63e9e09c87b70d47d7aefadcbb80d1e7368bc2cf096
openproject-token (8.9.0) sha256=aa08c144889010750de4edaf61f8614ccb82ac6c63beef1d3a21c6a222358605
openproject-two_factor_authentication (1.0.0)
openproject-webhooks (1.0.0)
openproject-wikis (1.0.0)
Binary file not shown.
@@ -29,7 +29,11 @@ See COPYRIGHT and LICENSE files for more details.
<%=
render(Primer::Beta::Text.new(tag: :div, color: :muted, mb: 3)) do
I18n.t("backlogs.sharing_description")
if only_fallback_allowed
t(".sharing_fallback_description")
else
t(".sharing_description")
end
end
%>
@@ -40,6 +44,6 @@ See COPYRIGHT and LICENSE files for more details.
method: :patch,
data: { turbo_frame: "_top", controller: "show-when-value-selected" }
) do |f|
render(Projects::Settings::Backlogs::SharingForm.new(f))
render(Projects::Settings::Backlogs::SharingForm.new(f, only_fallback_allowed:))
end
%>
@@ -35,15 +35,17 @@ module Projects
include ApplicationHelper
include OpPrimer::ComponentHelpers
def initialize(project:)
def initialize(project:,
only_fallback_allowed: false)
super
@project = project
@only_fallback_allowed = only_fallback_allowed
end
private
attr_reader :project
attr_reader :project, :only_fallback_allowed
end
end
end
@@ -36,6 +36,7 @@ module Projects
validate :validate_global_sprint_sharer_uniqueness
validates :sprint_sharing, presence: true
validates :sprint_sharing, inclusion: { in: Project::SPRINT_SHARING_MODES }, allow_blank: true
validate :validate_sprint_sharing_in_ee_token
def validate_model? = false
@@ -59,5 +60,19 @@ module Projects
end
end
end
def validate_sprint_sharing_in_ee_token
if !model.not_sharing_sprints? &&
!EnterpriseToken.allows_to?(:sprint_sharing) &&
sprint_sharing_changed?
errors.add :sprint_sharing,
:enterprise_plan_required,
plan_name: I18n.t("ee.upsell.plan_name", plan: OpenProject::Token.lowest_plan_for(:sprint_sharing))
end
end
def sprint_sharing_changed?
model.settings_change&.any? { it.key?("sprint_sharing") }
end
end
end
@@ -36,27 +36,34 @@ module Projects
# TODO: Remove this hidden field, once the `radio_button_group` supports rendering
# the hidden empty field.
# The purpose of the hidden field is to ensure we submit the `sprint_sharing` field
# even if no radio button is chosen. Otherwise, the submitted form will not include
# even if:
# * no radio button is chosen.
# * the selected option is disabled because of a missing EE token.
# Otherwise, the submitted form will not include
# the field at all and the save request will return success when in fact no setting
# is saved.
# Ideally the hidden field should automatically be rendered by the `radio_button_group`
# helper, similar to how the `collection_radio_buttons` rails helper does.
sharing_form.hidden(name: :sprint_sharing, value: "")
sharing_form.hidden(name: :sprint_sharing, value: model.sprint_sharing)
sharing_form.radio_button_group(
name: :sprint_sharing,
label: I18n.t("projects.settings.backlog_sharing.sprint_sharing")
) do |group|
Project::SPRINT_SHARING_MODES.each do |option|
group.radio_button(
label: sharing_option_text(option, :label),
value: option,
checked: checked?(option),
disabled: disabled?(option),
caption: caption_for(option),
data: { "show-when-value-selected-target": "cause" }
)
end
group_radio_button(group,
sharing: Project::NO_SHARING,
disabled: false)
group_radio_button(group,
sharing: Project::SHARE_ALL_PROJECTS,
disabled: only_fallback_allowed || all_projects_shared_by_other_project?,
caption: shared_all_projects_caption)
group_radio_button(group,
sharing: Project::SHARE_SUBPROJECTS)
group_radio_button(group,
sharing: Project::RECEIVE_SHARED)
end
sharing_form.html_content { banner_for(Project::SHARE_SUBPROJECTS, type: :info) }
@@ -69,33 +76,41 @@ module Projects
)
end
private
def checked?(option)
option == model.sprint_sharing
def initialize(only_fallback_allowed: false)
super()
@only_fallback_allowed = only_fallback_allowed
end
def disabled?(option)
option == Project::SHARE_ALL_PROJECTS && share_all_projects_disabled?
private
attr_reader :only_fallback_allowed
def group_radio_button(group,
sharing:,
disabled: only_fallback_allowed,
caption: sharing_option_caption(sharing))
group.radio_button(
label: sharing_option_label(sharing),
value: sharing,
caption:,
disabled:,
data: { "show-when-value-selected-target": "cause" }
)
end
def sharing_option_caption(option)
sharing_option_text(option, :caption)
end
def sharing_option_label(option)
sharing_option_text(option, :label)
end
def sharing_option_text(option, key, **)
I18n.t("projects.settings.backlog_sharing.options.#{option}.#{key}", **)
end
def caption_for(option)
if disabled?(option)
if User.current.allowed_in_project?(:view_project, global_sprint_sharer)
sharing_option_text(option, :disabled_caption, name: global_sprint_sharer.name)
else
sharing_option_text(option, :disabled_caption_anonymous)
end
else
sharing_option_text(option, :caption)
end
end
def share_all_projects_disabled?
def all_projects_shared_by_other_project?
global_sprint_sharer && global_sprint_sharer != model
end
@@ -117,6 +132,21 @@ module Projects
end
end
end
def shared_all_projects_caption
if all_projects_shared_by_other_project?
if User.current.allowed_in_project?(:view_project, global_sprint_sharer)
sharing_option_text(Project::SHARE_ALL_PROJECTS,
:disabled_caption,
name: global_sprint_sharer.name)
else
sharing_option_text(Project::SHARE_ALL_PROJECTS,
:disabled_caption_anonymous)
end
else
sharing_option_caption(Project::SHARE_ALL_PROJECTS)
end
end
end
end
end
@@ -35,5 +35,22 @@ See COPYRIGHT and LICENSE files for more details.
)
) %>
<%= render(Projects::Settings::Backlogs::SharingFormComponent.new(project: @project)) %>
<% with_enterprise_banner_guard(
:sprint_sharing,
# We want to enable users to migrate out of sprint sharing if their token expired.
# If that happens, an inline banner is to be displayed.
# But there is the special case of trial tokens. For those, two inline banners would be displayed
# which is why this is handled explicitly here.
inactive_guard: !@project.not_sharing_sprints? || EnterpriseToken.trialling?(:sprint_sharing),
variant: :large,
video: "enterprise/sprint-sharing.mp4"
) do %>
<%= render(EnterpriseEdition::BannerComponent.new(:sprint_sharing, variant: :inline)) %>
<%= render(
Projects::Settings::Backlogs::SharingFormComponent.new(
project: @project,
only_fallback_allowed: !EnterpriseToken.allows_to?(:sprint_sharing)
)
) %>
<% end %>
<% end %>
+12 -1
View File
@@ -159,7 +159,6 @@ en:
rebuild_positions: "Rebuild positions"
remaining_hours: "Remaining work"
sharing: "Sharing"
sharing_description: "This project can either share its own sprints, receive shared sprints or handle sprints independently (no sharing)."
show_burndown_chart: "Burndown chart"
sprint_component:
@@ -220,6 +219,13 @@ en:
story_points: "Story points"
story_points_ideal: "Story points (ideal)"
ee:
features:
sprint_sharing: "Sprint sharing"
upsell:
sprint_sharing:
description: "Share sprints across projects to align teams and coordinate work in scaled agile setups (SAFe)."
label_backlog: "Backlog"
label_backlog_bucket_edit: "Edit backlog bucket"
label_backlog_bucket_new: "New backlog bucket"
@@ -274,4 +280,9 @@ en:
info: "Sharing a sprint will share the name, status and the start and finish dates in all projects. These cannot be modified in projects that receive and use these sprints."
sprint_sharing: Share sprints
backlogs:
sharing_form_component:
sharing_description: "This project can either share its own sprints, receive shared sprints or handle sprints independently (no sharing)."
sharing_fallback_description: "Lacking a corporate enterprise plan, the sharing options are limited to the project's own sprints. The currently active setting remains active."
remaining_hours: "remaining work"
@@ -38,7 +38,11 @@ module OpenProject::Backlogs::Patches::CopyServicePatch
module InstanceMethods
def clean_settings_attributes!(settings)
# There can be only one project sharing with all projects.
settings.delete("sprint_sharing") if settings["sprint_sharing"] == Projects::SprintSharing::SHARE_ALL_PROJECTS
if settings["sprint_sharing"] == Projects::SprintSharing::SHARE_ALL_PROJECTS ||
!EnterpriseToken.allows_to?(:sprint_sharing)
settings.delete("sprint_sharing")
end
super
end
@@ -3,11 +3,11 @@
require "spec_helper"
require "contracts/shared/model_contract_shared_context"
RSpec.describe Projects::BacklogSettingsContract, type: :model do
RSpec.describe Projects::BacklogSettingsContract, type: :model, with_ee: %i[sprint_sharing] do
include_context "ModelContract shared context"
let(:current_user) { build_stubbed(:user) }
let(:project) { create(:project) }
let(:project) { build_stubbed(:project) }
let(:permissions) { %i(share_sprint) }
subject(:contract) { described_class.new(project, current_user) }
@@ -35,7 +35,7 @@ RSpec.describe Projects::BacklogSettingsContract, type: :model do
# This spec of explicitly setting sprint_sharing to empty is required because the
# simple presence validation spec is not sufficient to catch certain corner cases.
# For example, when the sprint_sharing getter is overriden to provide a default value,
# For example, when the sprint_sharing getter is overridden to provide a default value,
# and the user submits an empty value, the contract should be invalid.
context "when sprint_sharing is empty" do
before { project.sprint_sharing = "" }
@@ -57,6 +57,62 @@ RSpec.describe Projects::BacklogSettingsContract, type: :model do
end
end
context "when the `sprint_sharing` is not part of the current EE token", with_ee: [] do
context "when sprint sharing is set to 'no_sharing'" do
before { project.sprint_sharing = Project::NO_SHARING }
it_behaves_like "contract is valid"
end
context "when sprint sharing is set to 'share_all_projects'" do
before { project.sprint_sharing = Project::SHARE_ALL_PROJECTS }
it_behaves_like "contract is invalid",
sprint_sharing: { error: :enterprise_plan_required, plan_name: "corporate enterprise plan" }
end
context "when sprint sharing is set to 'share_subprojects'" do
before { project.sprint_sharing = Project::SHARE_SUBPROJECTS }
it_behaves_like "contract is invalid",
sprint_sharing: { error: :enterprise_plan_required, plan_name: "corporate enterprise plan" }
end
context "when sprint sharing is set to 'receive_shared'" do
before { project.sprint_sharing = Project::RECEIVE_SHARED }
it_behaves_like "contract is invalid",
sprint_sharing: { error: :enterprise_plan_required, plan_name: "corporate enterprise plan" }
end
context "when sprint sharing remains on 'share_all_projects'" do
before do
project.sprint_sharing = Project::SHARE_ALL_PROJECTS
project.clear_changes_information
end
it_behaves_like "contract is valid"
end
context "when sprint sharing remains on 'share_subprojects'" do
before do
project.sprint_sharing = Project::SHARE_SUBPROJECTS
project.clear_changes_information
end
it_behaves_like "contract is valid"
end
context "when sprint sharing remains on 'receive_shared'" do
before do
project.sprint_sharing = Project::RECEIVE_SHARED
project.clear_changes_information
end
it_behaves_like "contract is valid"
end
end
describe "#validate_global_sprint_sharer_uniqueness" do
before do
project.sprint_sharing = "share_all_projects"
@@ -42,7 +42,8 @@ RSpec.describe "Backlogs project settings sprint sharing", :js do
login_as current_user
end
context "with share_sprint permission" do
context "with share_sprint permission and enterprise token",
with_ee: [:sprint_sharing] do
it "displays and stores sprint sharing settings" do
visit project_settings_backlog_sharing_path(project)
@@ -127,6 +128,50 @@ RSpec.describe "Backlogs project settings sprint sharing", :js do
end
end
context "with share_sprint permission but no enterprise token" do
context "without existing sharing setting in the project" do
it "shows an enterprise token teaser" do
visit project_settings_backlog_sharing_path(project)
expect(page).to have_text("Share sprints across projects to align teams")
expect(page).to have_no_field("Don't share")
expect(page).to have_no_field("All projects")
expect(page).to have_no_field("Subprojects")
expect(page).to have_no_field("Receive shared sprints")
end
end
context "with existing sharing setting in the project" do
before do
project.update!(sprint_sharing: "receive_shared")
end
it "shows the existing sharing setting but disables them except for `Don't share`" do
visit project_settings_backlog_sharing_path(project)
# All radio buttons are present with the selected option displayed.
# But all except the "Don't share" option are disabled.
expect(page).to have_unchecked_field("Don't share")
expect(page).to have_unchecked_field("All projects", disabled: true)
expect(page).to have_unchecked_field("Subprojects", disabled: true)
expect(page).to have_checked_field("Receive shared sprints", disabled: true)
choose("Don't share")
click_button "Save"
# Now that the `Don't share` option is selected, the large enterprise banner is displayed.
expect(page).to have_text("Share sprints across projects to align teams")
expect(page).to have_no_field("Don't share")
expect(page).to have_no_field("All projects")
expect(page).to have_no_field("Subprojects")
expect(page).to have_no_field("Receive shared sprints")
end
end
end
context "without share_sprint permission" do
let(:permissions) { %i[create_sprints select_backlog_types_and_statuses] }
@@ -58,51 +58,69 @@ RSpec.describe Projects::CopyService, "integration", type: :model do
subject { instance.call(params) }
describe "#sprint_sharing setting" do
context "when the source project is set to receive" do
before do
source.sprint_sharing = Projects::SprintSharing::RECEIVE_SHARED
source.save!
context "with an ee license for sprint sharing", with_ee: %i[sprint_sharing] do
context "when the source project is set to receive" do
before do
source.sprint_sharing = Projects::SprintSharing::RECEIVE_SHARED
source.save!
end
it "copies the backlog sharing setting" do
expect(subject).to be_success
expect(project_copy.sprint_sharing).to eq Projects::SprintSharing::RECEIVE_SHARED
end
end
it "copies the backlog sharing setting" do
expect(subject).to be_success
expect(project_copy.sprint_sharing).to eq Projects::SprintSharing::RECEIVE_SHARED
context "when the source project is set to share with subprojects" do
before do
source.sprint_sharing = Projects::SprintSharing::SHARE_SUBPROJECTS
source.save!
end
it "copies the backlog sharing setting" do
expect(subject).to be_success
expect(project_copy.sprint_sharing).to eq Projects::SprintSharing::SHARE_SUBPROJECTS
end
end
context "when the source project is set to not share" do
before do
source.sprint_sharing = Projects::SprintSharing::NO_SHARING
source.save!
end
it "copies the backlog sharing setting" do
expect(subject).to be_success
expect(project_copy.sprint_sharing).to eq Projects::SprintSharing::NO_SHARING
end
end
context "when the source project is set to share with all" do
before do
source.sprint_sharing = Projects::SprintSharing::SHARE_ALL_PROJECTS
source.save!
end
it "does not copy the setting as that would result in two projects sharing with all" do
expect(subject).to be_success
expect(project_copy.sprint_sharing).to eq Projects::SprintSharing::NO_SHARING
end
end
end
context "when the source project is set to share with subprojects" do
before do
source.sprint_sharing = Projects::SprintSharing::SHARE_SUBPROJECTS
source.save!
end
context "without an ee license for sprint sharing", with_ee: %i[] do
Projects::SprintSharing::SPRINT_SHARING_MODES.each do |mode|
context "when the source project is set to #{mode}" do
before do
source.sprint_sharing = mode
source.save!
end
it "copies the backlog sharing setting" do
expect(subject).to be_success
expect(project_copy.sprint_sharing).to eq Projects::SprintSharing::SHARE_SUBPROJECTS
end
end
context "when the source project is set to not share" do
before do
source.sprint_sharing = Projects::SprintSharing::NO_SHARING
source.save!
end
it "copies the backlog sharing setting" do
expect(subject).to be_success
expect(project_copy.sprint_sharing).to eq Projects::SprintSharing::NO_SHARING
end
end
context "when the source project is set to share with all" do
before do
source.sprint_sharing = Projects::SprintSharing::SHARE_ALL_PROJECTS
source.save!
end
it "does not copy the setting as that would result in two projects sharing with all" do
expect(subject).to be_success
expect(project_copy.sprint_sharing).to eq Projects::SprintSharing::NO_SHARING
it "copies the backlog sharing setting" do
expect(subject).to be_success
expect(project_copy.sprint_sharing).to eq Projects::SprintSharing::NO_SHARING
end
end
end
end
end
@@ -48,7 +48,11 @@ RSpec.shared_context "ModelContract shared context" do # rubocop:disable RSpec/C
[error_symbols]
end
end
contract_errors = errors.keys.index_with { |key| contract.errors.symbols_for(key) }
contract_errors = errors.keys.index_with do |key|
errors[key].is_a?(Hash) ? contract.errors.details[key] : contract.errors.symbols_for(key)
end
expect(contract_errors).to match(expected_errors)
if RSpec.current_example.metadata[:check_errors_i18n]
# ensure no I18n::MissingTranslationData is raised because of missing attributes and/or errors translations