From 3511b80b9b5071f7a11ea9d27b1aec2ac4ff7311 Mon Sep 17 00:00:00 2001 From: Jan Sandbrink Date: Tue, 9 Jun 2026 16:46:38 +0200 Subject: [PATCH] Wire-up page search using URL to dialogs This allows to use the URL of a wiki page during selection of a page to link or the parent of a page to be created. --- .../app/controllers/wikis/pages_controller.rb | 9 +- .../app/services/wikis/page_search_service.rb | 80 +++++++++++ modules/wikis/config/locales/en.yml | 2 +- .../wikis/page_search_service_spec.rb | 133 ++++++++++++++++++ 4 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 modules/wikis/app/services/wikis/page_search_service.rb create mode 100644 modules/wikis/spec/services/wikis/page_search_service_spec.rb diff --git a/modules/wikis/app/controllers/wikis/pages_controller.rb b/modules/wikis/app/controllers/wikis/pages_controller.rb index d1c94680813..8f373bda42b 100644 --- a/modules/wikis/app/controllers/wikis/pages_controller.rb +++ b/modules/wikis/app/controllers/wikis/pages_controller.rb @@ -31,7 +31,6 @@ module Wikis class PagesController < ApplicationController include OpTurbo::ComponentStream - include Dry::Monads[:result] before_action :authorize, except: %i[search] @@ -69,13 +68,7 @@ module Wikis private def search_pages(query, provider) - return Success([]) if query.blank? - - Adapters::Input::SearchPages.build(query:).bind do |input_data| - provider.auth_strategy_for(current_user).bind do |auth_strategy| - provider.resolve("queries.search_pages").call(input_data:, auth_strategy:) - end - end + PageSearchService.new(provider:, user: current_user).search_pages(query) end def create_new_page_params diff --git a/modules/wikis/app/services/wikis/page_search_service.rb b/modules/wikis/app/services/wikis/page_search_service.rb new file mode 100644 index 00000000000..29e46c4c073 --- /dev/null +++ b/modules/wikis/app/services/wikis/page_search_service.rb @@ -0,0 +1,80 @@ +# 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 Wikis + class PageSearchService + include Dry::Monads[:result] + + attr_reader :provider, :user + + def initialize(provider:, user:) + @provider = provider + @user = user + end + + def search_pages(query) + return Success([]) if query.blank? + + search_by_url(query).or do |error| + return Failure(error) if error.code != :not_found + + search_by_query(query) + end + end + + private + + def search_by_url(query) + return Failure(Adapters::Results::Error.new(source: self.class, code: :not_found)) unless url?(query) + + Adapters::Input::PageInfoForUrl.build(url: query).bind do |input_data| + provider.auth_strategy_for(user).bind do |auth_strategy| + provider.resolve("queries.page_info_for_url").call(input_data:, auth_strategy:).fmap { [it] } + end + end + end + + def search_by_query(query) + Adapters::Input::SearchPages.build(query:).bind do |input_data| + provider.auth_strategy_for(user).bind do |auth_strategy| + provider.resolve("queries.search_pages").call(input_data:, auth_strategy:) + end + end + end + + def url?(string) + uri = URI.parse(string) + + %w[http https].include?(uri.scheme) + rescue URI::InvalidURIError + false + end + end +end diff --git a/modules/wikis/config/locales/en.yml b/modules/wikis/config/locales/en.yml index b67984ec956..728fc055342 100644 --- a/modules/wikis/config/locales/en.yml +++ b/modules/wikis/config/locales/en.yml @@ -152,7 +152,7 @@ en: title: Add existing wiki page link_existing_wiki_page_form: no_results: No wiki pages found - placeholder: Search for a wiki page + placeholder: Search for a wiki page (or enter its URL) oauth_login_component: connect_button: Connect %{provider} account description: Log in to %{provider} to view and manage related wiki pages from this OpenProject instance. diff --git a/modules/wikis/spec/services/wikis/page_search_service_spec.rb b/modules/wikis/spec/services/wikis/page_search_service_spec.rb new file mode 100644 index 00000000000..0e06566adae --- /dev/null +++ b/modules/wikis/spec/services/wikis/page_search_service_spec.rb @@ -0,0 +1,133 @@ +# 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. +#++ + +require "spec_helper" +require_module_spec_helper + +RSpec.describe Wikis::PageSearchService do + subject { described_class.new(provider:, user:).search_pages(query) } + + let(:provider) { create(:xwiki_provider, :with_connected_user, connected_user: user) } + let(:user) { create(:user) } + + let(:page_info_for_url) do + instance_double(Wikis::Adapters::Providers::XWiki::Queries::PageInfoForUrl, call: page_info_result) + end + let(:search_pages) do + instance_double(Wikis::Adapters::Providers::XWiki::Queries::SearchPages, call: search_pages_result) + end + + let(:page_info_result) { Success("a single page info") } + let(:search_pages_result) { Success(["a lot of page infos"]) } + + before do + Wikis::Adapters::Registry.stub( + "xwiki.queries.page_info_for_url", + class_double(Wikis::Adapters::Providers::XWiki::Queries::PageInfoForUrl, new: page_info_for_url) + ) + Wikis::Adapters::Registry.stub( + "xwiki.queries.search_pages", + class_double(Wikis::Adapters::Providers::XWiki::Queries::SearchPages, new: search_pages) + ) + end + + context "when the query is a normal search term" do + let(:query) { "search term" } + + it "does not try to resolve the page by URL" do + subject + + expect(page_info_for_url).not_to have_received(:call) + end + + it "returns the result of search pages" do + expect(subject).to be_success + expect(subject.value!).to eq(["a lot of page infos"]) + end + + it "passes the search term along" do + subject + expect(search_pages).to have_received(:call).with(input_data: having_attributes(query:), auth_strategy: anything) + end + end + + context "when the query is a URL" do + let(:query) { "https://example.com" } + + it "does not try to resolve a search query" do + subject + + expect(search_pages).not_to have_received(:call) + end + + it "resolves the page by URL" do + expect(subject).to be_success + expect(subject.value!).to be_a(Array) + expect(subject.value!).to eq(["a single page info"]) + end + + it "passes the URL along" do + subject + expect(page_info_for_url).to have_received(:call).with(input_data: having_attributes(url: query), auth_strategy: anything) + end + + context "and when no page with the URL can be found" do + let(:page_info_result) { Failure(Wikis::Adapters::Results::Error.new(code: :not_found, source: self)) } + + it "returns the result of search pages" do + expect(subject).to be_success + expect(subject.value!).to eq(["a lot of page infos"]) + end + end + + context "and when finding the page by URL fails" do + let(:page_info_result) { Failure(Wikis::Adapters::Results::Error.new(code: :unexpected, source: self)) } + + it "returns an error" do + expect(subject).to eq(page_info_result) + end + end + end + + context "when the query contains a URL in the search term" do + let(:query) { "https://example.com does not load" } + + it "does not try to resolve the page by URL" do + subject + + expect(page_info_for_url).not_to have_received(:call) + end + + it "returns the result of search pages" do + expect(subject).to be_success + expect(subject.value!).to eq(["a lot of page infos"]) + end + end +end