diff --git a/app/components/work_packages/admin/settings/identifier_settings_form_component.rb b/app/components/work_packages/admin/settings/identifier_settings_form_component.rb index 9583ab3c5d91..922c2127d28f 100644 --- a/app/components/work_packages/admin/settings/identifier_settings_form_component.rb +++ b/app/components/work_packages/admin/settings/identifier_settings_form_component.rb @@ -64,12 +64,19 @@ def has_problematic_projects? def form_id = "wp-identifier-settings-form" def in_progress_banner_message - key = if ProjectIdentifiers::IdentifierAutofix.reversion_in_progress? - "admin.settings.work_packages_identifier.in_progress.reverting_banner_message" - else - "admin.settings.work_packages_identifier.in_progress.converting_banner_message" - end - I18n.t(key) + if ProjectIdentifiers::IdentifierAutofix.reversion_in_progress? + pending_count = Project.with_non_classic_identifier.count + I18n.t("admin.settings.work_packages_identifier.in_progress.classic_conversion_status_message", + count: pending_count) + else + pending_count = ProjectIdentifiers::PendingProjectsFinder.project_ids.size + scope = "admin.settings.work_packages_identifier.in_progress.semantic_conversion_status_message" + if pending_count.zero? + I18n.t("#{scope}.zero") + else + I18n.t(scope, count: pending_count) + end + end end def show_autofix_section? diff --git a/app/controllers/admin/settings/work_packages_identifier_controller.rb b/app/controllers/admin/settings/work_packages_identifier_controller.rb index a7ec6be52941..b7290939fc32 100644 --- a/app/controllers/admin/settings/work_packages_identifier_controller.rb +++ b/app/controllers/admin/settings/work_packages_identifier_controller.rb @@ -55,14 +55,11 @@ def confirm_dialog end def status - if ProjectIdentifiers::IdentifierAutofix.job_in_progress? - head :no_content - else - replace_via_turbo_stream( - component: WorkPackages::Admin::Settings::IdentifierSettingsFormComponent.new(state: :completed) - ) - respond_with_turbo_streams - end + state = ProjectIdentifiers::IdentifierAutofix.job_in_progress? ? :change_in_progress : :completed + replace_via_turbo_stream( + component: WorkPackages::Admin::Settings::IdentifierSettingsFormComponent.new(state:) + ) + respond_with_turbo_streams end private diff --git a/app/models/projects/identifier.rb b/app/models/projects/identifier.rb index b9835e3aff53..41419d2e2719 100644 --- a/app/models/projects/identifier.rb +++ b/app/models/projects/identifier.rb @@ -33,9 +33,14 @@ module Projects::Identifier CLASSIC_IDENTIFIER_MAX_LENGTH = 100 SEMANTIC_IDENTIFIER_MAX_LENGTH = 10 - # Classic identifier format: lowercase letters, digits, hyphens, underscores — but not all-numeric. - CLASSIC_IDENTIFIER_FORMAT = /\A(?!\d+\z)[a-z0-9\-_]+\z/ + # Classic format validation regexes: + # Simple character set meant only for SQL matching. No anchoring or anti-all-numeric guard. + CLASSIC_FORMAT_CHARS = /[a-z0-9\-_]+/ + # Anchored form with an anti-all-numeric guard. + CLASSIC_FORMAT = /\A(?!\d+\z)#{CLASSIC_FORMAT_CHARS}\z/ + + # Semantic format validation regex: # Unanchored shape of a semantic project identifier ("PROJ", "MY_PROJECT_1"). # Composed into `WorkPackage::SemanticIdentifier::SEMANTIC_ID_PATTERN`. SEMANTIC_FORMAT = /[A-Z][A-Z0-9_]*/ @@ -114,7 +119,11 @@ def raw_values = pluck(:slug) class_methods do def classic_identifier_format?(str) - str.match?(CLASSIC_IDENTIFIER_FORMAT) + str.match?(CLASSIC_FORMAT) + end + + def with_non_classic_identifier + where("identifier !~ ?", "^#{CLASSIC_FORMAT_CHARS.source}$") end # FriendlyId's :history module records a row on every save, so this relation contains diff --git a/config/locales/en.yml b/config/locales/en.yml index 46c674d24539..4724b08d6347 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -449,8 +449,13 @@ en: checkbox_label: I understand that this will permanently change all work package IDs success_banner: Successfully updated work package identifier format. in_progress: - converting_banner_message: Project identifiers are currently being converted to semantic format. This may take some time. - reverting_banner_message: Project identifiers are currently being reverted to classic format. This may take some time. + semantic_conversion_status_message: + zero: Finalizing conversion to semantic format... + one: Project identifiers are being converted to semantic format. 1 project remaining. + other: Project identifiers are being converted to semantic format. %{count} projects remaining. + classic_conversion_status_message: + one: Project identifiers are being converted to classic format. 1 project remaining. + other: Project identifiers are being converted to classic format. %{count} projects remaining. workflows: tabs: default_transitions: "Default transitions" diff --git a/spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb b/spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb index 07f63ed26e40..847ae94ffcee 100644 --- a/spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb +++ b/spec/components/work_packages/admin/settings/identifier_settings_form_component_spec.rb @@ -53,9 +53,46 @@ def render_component(component) context "when state is :change_in_progress" do let(:state) { :change_in_progress } - it "renders the in-progress spinner message" do + before do + allow(ProjectIdentifiers::IdentifierAutofix).to receive(:reversion_in_progress?).and_return(false) + allow(ProjectIdentifiers::PendingProjectsFinder).to receive(:project_ids).and_return(Set.new(1..7)) + end + + it "renders the converting banner with the pending project count" do render_component(component) - expect(page).to have_text("Project identifiers are currently being converted to semantic format.") + expect(page).to have_text("7 projects remaining") + end + + context "with 1 project remaining" do + before { allow(ProjectIdentifiers::PendingProjectsFinder).to receive(:project_ids).and_return(Set[42]) } + + it "uses the singular form" do + render_component(component) + expect(page).to have_text("1 project remaining") + end + end + + context "with 0 projects remaining" do + before { allow(ProjectIdentifiers::PendingProjectsFinder).to receive(:project_ids).and_return(Set.new) } + + it "renders the finalizing message" do + render_component(component) + expect(page).to have_text("Finalizing conversion to semantic format") + expect(page).to have_no_text("projects remaining") + end + end + + context "when reversion is in progress" do + before do + allow(ProjectIdentifiers::IdentifierAutofix).to receive(:reversion_in_progress?).and_return(true) + allow(Project).to receive(:with_non_classic_identifier).and_return(double(count: 3)) + end + + it "renders the converting-to-classic banner with the pending project count" do + render_component(component) + expect(page).to have_text("3 projects remaining") + expect(ProjectIdentifiers::PendingProjectsFinder).not_to have_received(:project_ids) + end end it "does not render the success banner" do @@ -91,7 +128,7 @@ def render_component(component) it "does not render the in-progress spinner message" do render_component(component) - expect(page).to have_no_text("Project identifiers are currently being converted to semantic format.") + expect(page).to have_no_text("projects remaining") end it "renders the radio buttons as enabled" do @@ -137,7 +174,7 @@ def render_component(component) it "does not render in-progress or success content" do render_component(component) - expect(page).to have_no_text("Project identifiers are currently being converted to semantic format.") + expect(page).to have_no_text("projects remaining") expect(page).to have_no_text("Successfully updated work package identifier format.") end diff --git a/spec/controllers/admin/settings/work_packages_identifier_controller_spec.rb b/spec/controllers/admin/settings/work_packages_identifier_controller_spec.rb index ff61b21fa9af..1a8aa9a26aed 100644 --- a/spec/controllers/admin/settings/work_packages_identifier_controller_spec.rb +++ b/spec/controllers/admin/settings/work_packages_identifier_controller_spec.rb @@ -36,6 +36,36 @@ current_user { user } + describe "GET #status" do + let(:component_target) { "work-packages-admin-settings-identifier-settings-form-component" } + + context "when a migration job is in progress" do + before do + allow(ProjectIdentifiers::IdentifierAutofix).to receive(:job_in_progress?).and_return(true) + end + + it "returns 200 with a turbo stream replacing the form component in change_in_progress state" do + get :status, format: :turbo_stream + + expect(response).to have_http_status(:ok) + expect(response).to have_turbo_stream(action: "replace", target: component_target) + end + end + + context "when no migration job is in progress" do + before do + allow(ProjectIdentifiers::IdentifierAutofix).to receive(:job_in_progress?).and_return(false) + end + + it "returns 200 with a turbo stream replacing the form component in completed state" do + get :status, format: :turbo_stream + + expect(response).to have_http_status(:ok) + expect(response).to have_turbo_stream(action: "replace", target: component_target) + end + end + end + describe "PATCH #update" do context "when work_packages_identifier is 'semantic'" do it "enqueues ProjectIdentifiers::ConvertInstanceToSemanticIdsJob and redirects" do diff --git a/spec/workers/project_identifiers/revert_instance_to_classic_ids_job_spec.rb b/spec/workers/project_identifiers/revert_instance_to_classic_ids_job_spec.rb index 0bfcc3bf3e6f..2ff40ac9f346 100644 --- a/spec/workers/project_identifiers/revert_instance_to_classic_ids_job_spec.rb +++ b/spec/workers/project_identifiers/revert_instance_to_classic_ids_job_spec.rb @@ -109,7 +109,7 @@ it "generates a valid classic identifier when no classic history exists" do expect(project_without_classic_history.reload.identifier) - .to match(Projects::Identifier::CLASSIC_IDENTIFIER_FORMAT) + .to match(Projects::Identifier::CLASSIC_FORMAT) end end end