From 322ee31ef9182035b7b42657ef75fd61a45d2f54 Mon Sep 17 00:00:00 2001 From: Jan Sandbrink Date: Tue, 22 Jul 2025 14:48:53 +0200 Subject: [PATCH] Extract Wizard service class to the core Allowing it to be reused across OpenProject and not just inside the storages module. --- app/services/wizard.rb | 121 ++++++++++++++++++ .../storages/app/services/storages/wizard.rb | 110 ---------------- .../storages => spec/services}/wizard_spec.rb | 29 ++++- 3 files changed, 145 insertions(+), 115 deletions(-) create mode 100644 app/services/wizard.rb delete mode 100644 modules/storages/app/services/storages/wizard.rb rename {modules/storages/spec/services/storages => spec/services}/wizard_spec.rb (82%) diff --git a/app/services/wizard.rb b/app/services/wizard.rb new file mode 100644 index 00000000000..84f7eaeb47d --- /dev/null +++ b/app/services/wizard.rb @@ -0,0 +1,121 @@ +# 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 Wizard + StepDefinition = Data.define(:name, :section, :if, :completed_if, :preparation) do + def use_on?(wizard) + call_with_model(self.if, wizard) + end + + def completed_on?(wizard) + call_with_model(completed_if, wizard) + end + + def prepare_on(wizard) + call_with_model(preparation, wizard) + end + + private + + def call_with_model(sym_or_callable, wizard) + if sym_or_callable.is_a? Symbol + wizard.send(sym_or_callable, wizard.model) + else + sym_or_callable.call(wizard.model) + end + end + end + + class << self + def steps_by_name + @steps_by_name ||= {} + end + + def steps + steps_by_name.values + end + + def step(name, completed_if:, if: ->(_) { true }, preparation: ->(_) {}, section: name) + name = name.to_sym + steps_by_name[name] = StepDefinition.new(name:, section:, if:, completed_if:, preparation:) + end + end + + attr_reader :model, :user + + def initialize(model:, user: nil) + @model = model + @user = user + end + + # Determines the next step, given a current step in the wizard + def step_after(current_step) + current_step_idx = steps.find_index { |s| s == current_step } + return nil if current_step_idx.nil? + + steps[current_step_idx + 1] + end + + # Determines the next step given the model state and the `completed_if` conditions that are defined on the wizard + def foresee_next_step + pending_steps.first + end + + def prepare_next_step + next_step = foresee_next_step + return nil if next_step.nil? + + steps_by_name.fetch(next_step).prepare_on(self) + + next_step + end + + def section_name(name) + steps_by_name.fetch(name).section + end + + def steps + self.class.steps.select { |s| s.use_on?(self) }.map(&:name) + end + + def completed_steps + steps.select { |name| steps_by_name.fetch(name).completed_on?(self) } + end + + def pending_steps + steps.reject { |name| steps_by_name.fetch(name).completed_on?(self) } + end + + private + + def steps_by_name + self.class.steps_by_name + end +end diff --git a/modules/storages/app/services/storages/wizard.rb b/modules/storages/app/services/storages/wizard.rb deleted file mode 100644 index b9aa09cc597..00000000000 --- a/modules/storages/app/services/storages/wizard.rb +++ /dev/null @@ -1,110 +0,0 @@ -# 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. -#++ - -module Storages - class Wizard - StepDefinition = Data.define(:name, :section, :if, :completed_if, :preparation) do - def use_on?(wizard) - call_with_model(self.if, wizard) - end - - def completed_on?(wizard) - call_with_model(completed_if, wizard) - end - - def prepare_on(wizard) - call_with_model(preparation, wizard) - end - - private - - def call_with_model(sym_or_callable, wizard) - if sym_or_callable.is_a? Symbol - wizard.send(sym_or_callable, wizard.model) - else - sym_or_callable.call(wizard.model) - end - end - end - - class << self - def steps_by_name - @steps_by_name ||= {} - end - - def steps - steps_by_name.values - end - - def step(name, completed_if:, if: ->(_) { true }, preparation: ->(_) {}, section: name) - name = name.to_sym - steps_by_name[name] = StepDefinition.new(name:, section:, if:, completed_if:, preparation:) - end - end - - attr_reader :model, :user - - def initialize(model:, user: nil) - @model = model - @user = user - end - - def prepare_next_step - next_step = pending_steps.first - return nil if next_step.nil? - - steps_by_name.fetch(next_step).prepare_on(self) - - next_step - end - - def section_name(name) - steps_by_name.fetch(name).section - end - - def steps - self.class.steps.select { |s| s.use_on?(self) }.map(&:name) - end - - def completed_steps - steps.select { |name| steps_by_name.fetch(name).completed_on?(self) } - end - - def pending_steps - steps.reject { |name| steps_by_name.fetch(name).completed_on?(self) } - end - - private - - def steps_by_name - self.class.steps_by_name - end - end -end diff --git a/modules/storages/spec/services/storages/wizard_spec.rb b/spec/services/wizard_spec.rb similarity index 82% rename from modules/storages/spec/services/storages/wizard_spec.rb rename to spec/services/wizard_spec.rb index 228bdf4c4ba..b61519a5e47 100644 --- a/modules/storages/spec/services/storages/wizard_spec.rb +++ b/spec/services/wizard_spec.rb @@ -30,7 +30,7 @@ require "spec_helper" -RSpec.describe Storages::Wizard do +RSpec.describe Wizard do subject(:wizard) { wizard_class.new(model:) } let(:wizard_class) do @@ -65,6 +65,7 @@ RSpec.describe Storages::Wizard do step :b, completed_if: ->(i) { i > 4 }, if: :odd step :c, completed_if: ->(i) { i > 10 }, if: :even step :d, completed_if: ->(i) { i > 20 }, if: ->(i) { i.odd? } + step :e, completed_if: ->(i) { i > 30 } def odd(number) number.odd? @@ -77,7 +78,7 @@ RSpec.describe Storages::Wizard do end it "lists relevant steps" do - expect(wizard.steps).to eq(%i[b d]) + expect(wizard.steps).to eq(%i[b d e]) end it "lists relevant completed steps" do @@ -85,14 +86,23 @@ RSpec.describe Storages::Wizard do end it "lists relevant pending steps" do - expect(wizard.pending_steps).to eq(%i[d]) + expect(wizard.pending_steps).to eq(%i[d e]) + end + + it "determines next steps", :aggregate_failures do + expect(wizard.step_after(:a)).to be_nil + expect(wizard.step_after(:b)).to eq(:d) + expect(wizard.step_after(:c)).to be_nil + expect(wizard.step_after(:d)).to eq(:e) + expect(wizard.step_after(:e)).to be_nil + expect(wizard.step_after(:x)).to be_nil end context "when model matches alternative conditions" do let(:model) { 6 } it "lists relevant steps" do - expect(wizard.steps).to eq(%i[a c]) + expect(wizard.steps).to eq(%i[a c e]) end it "lists relevant completed steps" do @@ -100,7 +110,16 @@ RSpec.describe Storages::Wizard do end it "lists relevant pending steps" do - expect(wizard.pending_steps).to eq(%i[c]) + expect(wizard.pending_steps).to eq(%i[c e]) + end + + it "determines next steps", :aggregate_failures do + expect(wizard.step_after(:a)).to eq(:c) + expect(wizard.step_after(:b)).to be_nil + expect(wizard.step_after(:c)).to eq(:e) + expect(wizard.step_after(:d)).to be_nil + expect(wizard.step_after(:e)).to be_nil + expect(wizard.step_after(:x)).to be_nil end end end