diff --git a/modules/wikis/app/components/wikis/collapsible_page_links_component.html.erb b/modules/wikis/app/components/wikis/collapsible_page_links_component.html.erb index 4c832c794b94..6b153ce1eb45 100644 --- a/modules/wikis/app/components/wikis/collapsible_page_links_component.html.erb +++ b/modules/wikis/app/components/wikis/collapsible_page_links_component.html.erb @@ -36,8 +36,8 @@ See COPYRIGHT and LICENSE files for more details. end end - page_links.each do |page_link| - box.with_row { render(Wikis::PageLinkComponent.new(page_link)) } + page_links.each do |view_model| + box.with_row { render(Wikis::PageLinkComponent.new(view_model.page_info_result, source: view_model.source)) } end end %> diff --git a/modules/wikis/app/components/wikis/page_link_component.html.erb b/modules/wikis/app/components/wikis/page_link_component.html.erb index d67843a37eef..e93aeca088f0 100644 --- a/modules/wikis/app/components/wikis/page_link_component.html.erb +++ b/modules/wikis/app/components/wikis/page_link_component.html.erb @@ -37,11 +37,22 @@ See COPYRIGHT and LICENSE files for more details. %> <%= - render(Primer::Alpha::StackItem.new(grow: true, classes: "ellipsis")) do - if error? - render(Primer::Beta::Text.new(color: :muted)) { page_title } - else - render(Primer::Beta::Link.new(href: page_href, scheme: :primary, data: { allow_external_link: true })) { page_title } + render(Primer::Alpha::StackItem.new(grow: true)) do + render(Primer::Alpha::Stack.new(direction: :horizontal, gap: :condensed, align: :center)) do + concat( + render(Primer::Beta::Truncate.new) do |truncate| + truncate.with_item(priority: true) do + if error? + render(Primer::Beta::Text.new(color: :muted)) { page_title } + else + render(Primer::Beta::Link.new(href: page_href, scheme: :primary, data: { allow_external_link: true })) { page_title } + end + end + end + ) + if badge_label.present? + concat(render(Primer::Beta::Label.new(scheme: :secondary, test_selector: "wiki-page-link-source-badge")) { badge_label }) + end end end %> diff --git a/modules/wikis/app/components/wikis/page_link_component.rb b/modules/wikis/app/components/wikis/page_link_component.rb index 9da0f4654718..12ac567576f7 100644 --- a/modules/wikis/app/components/wikis/page_link_component.rb +++ b/modules/wikis/app/components/wikis/page_link_component.rb @@ -35,15 +35,20 @@ class PageLinkComponent < ApplicationComponent alias_method :page_info_result, :model - attr_reader :actions + attr_reader :actions, :source - def initialize(model = nil, actions: [], page_link: nil, **) + def initialize(model = nil, actions: [], page_link: nil, source: nil, **) @actions = actions @page_link = page_link + @source = source super(model, **) end + def badge_label + I18n.t("wikis.page_links.source.parent") if source == :link + end + def page_title page_info_result.either( ->(pi) { pi.title }, diff --git a/modules/wikis/app/components/wikis/page_link_view_model.rb b/modules/wikis/app/components/wikis/page_link_view_model.rb new file mode 100644 index 000000000000..d9d83e006058 --- /dev/null +++ b/modules/wikis/app/components/wikis/page_link_view_model.rb @@ -0,0 +1,43 @@ +# 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 + # View model for a single row in a CollapsiblePageLinksComponent. + # Mapping source -> badge label and styling is the component's concern. + PageLinkViewModel = Data.define(:page_info_result, :source) do + def self.from_reference(result) + new(page_info_result: result.fmap(&:page_info), source: result.fmap(&:source).value_or(nil)) + end + + def self.from_inline(result) + new(page_info_result: result, source: nil) + end + end +end diff --git a/modules/wikis/app/components/wikis/work_package_wikis_tab_component.rb b/modules/wikis/app/components/wikis/work_package_wikis_tab_component.rb index 5c0471b593b3..0cadcb83e10c 100644 --- a/modules/wikis/app/components/wikis/work_package_wikis_tab_component.rb +++ b/modules/wikis/app/components/wikis/work_package_wikis_tab_component.rb @@ -48,10 +48,12 @@ def show_inline_and_references_section? def inline_page_links @inline_page_links ||= page_link_service.inline_page_link_infos_for(linkable: work_package) + .map { PageLinkViewModel.from_inline(it) } end def referencing_wiki_pages @referencing_wiki_pages ||= page_link_service.referencing_wiki_page_infos_for(linkable: work_package) + .map { PageLinkViewModel.from_reference(it) } end private diff --git a/modules/wikis/app/models/wikis/adapters/results/page_reference.rb b/modules/wikis/app/models/wikis/adapters/results/page_reference.rb new file mode 100644 index 000000000000..7883cbe0ed17 --- /dev/null +++ b/modules/wikis/app/models/wikis/adapters/results/page_reference.rb @@ -0,0 +1,36 @@ +# 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::Adapters::Results + # Pairs a PageInfo with how it relates to a given work package. + # source: :link — page has a WorkPackage as a parent link + # source: :mention — page mentions the WP in its content + PageReference = Data.define(:page_info, :source) +end diff --git a/modules/wikis/app/services/wikis/adapters/providers/internal/queries/referencing_pages.rb b/modules/wikis/app/services/wikis/adapters/providers/internal/queries/referencing_pages.rb index 3893b8be6737..c89e085a72d2 100644 --- a/modules/wikis/app/services/wikis/adapters/providers/internal/queries/referencing_pages.rb +++ b/modules/wikis/app/services/wikis/adapters/providers/internal/queries/referencing_pages.rb @@ -40,7 +40,10 @@ def call(input_data:, auth_strategy:) .merge(ReverseInlinePageLink.all) .where(linkable: input_data.linkable) .order(created_at: :desc) - .map { page_info(identifier: it.identifier, auth_strategy:) } + .map do |link| + page_info(identifier: link.identifier, auth_strategy:) + .fmap { Wikis::Adapters::Results::PageReference.new(page_info: it, source: :link) } + end ) end end diff --git a/modules/wikis/app/services/wikis/adapters/providers/xwiki/queries/referencing_pages.rb b/modules/wikis/app/services/wikis/adapters/providers/xwiki/queries/referencing_pages.rb index 6245a0781655..fd2f98a62c62 100644 --- a/modules/wikis/app/services/wikis/adapters/providers/xwiki/queries/referencing_pages.rb +++ b/modules/wikis/app/services/wikis/adapters/providers/xwiki/queries/referencing_pages.rb @@ -47,8 +47,11 @@ def call(input_data:, auth_strategy:) authenticated(auth_strategy) do |http| fetch_reference_ids(http, input_data).bind do |reference_ids| fetch_mention_ids(http, input_data).bind do |mention_ids| - ids = (reference_ids + mention_ids).uniq - success(ids.map { canonical_page_info(identifier: it, auth_strategy:) }) + mention_only_ids = mention_ids - reference_ids + success( + canonical_pages(reference_ids, source: :link, auth_strategy:) + + canonical_pages(mention_only_ids, source: :mention, auth_strategy:) + ) end end end @@ -56,6 +59,13 @@ def call(input_data:, auth_strategy:) private + def canonical_pages(ids, source:, auth_strategy:) + ids.map do |id| + canonical_page_info(identifier: id, auth_strategy:) + .fmap { Wikis::Adapters::Results::PageReference.new(page_info: it, source:) } + end + end + def fetch_reference_ids(http, input_data) fetch_page_ids(http, rest_url("openproject/links/workPackages/#{input_data.linkable.id}"), params: { number: MAXIMUM_RESULTS, withInstance: instance_id }) diff --git a/modules/wikis/config/locales/en.yml b/modules/wikis/config/locales/en.yml index ac50c3383542..21ca85393b77 100644 --- a/modules/wikis/config/locales/en.yml +++ b/modules/wikis/config/locales/en.yml @@ -175,6 +175,8 @@ en: page_access_forbidden: You do not have permission to access this wiki page page_not_found: Linked wiki page no longer available unexpected: An unexpected error occurred + source: + parent: As parent provider_types: xwiki: name: XWiki diff --git a/modules/wikis/spec/components/wikis/page_link_component_spec.rb b/modules/wikis/spec/components/wikis/page_link_component_spec.rb index 29cab6f89e85..62b5029b515a 100644 --- a/modules/wikis/spec/components/wikis/page_link_component_spec.rb +++ b/modules/wikis/spec/components/wikis/page_link_component_spec.rb @@ -47,10 +47,11 @@ let(:page_info_result) { Success(page_info) } let(:permissions) { [:manage_wiki_page_links] } let(:actions) { [] } + let(:source) { nil } current_user { create(:user, member_with_permissions: { project => permissions }) } - subject(:render_component) { render_inline(described_class.new(page_info_result, actions:, page_link:)) } + subject(:render_component) { render_inline(described_class.new(page_info_result, actions:, page_link:, source:)) } before { render_component } @@ -58,6 +59,29 @@ expect(page).to have_link(text: page_info.title, href: page_info.href) end + context "when the page is referenced as a parent" do + let(:source) { :link } + + it "renders the parent badge" do + expect(page).to have_test_selector("wiki-page-link-source-badge", + text: I18n.t("wikis.page_links.source.parent")) + end + end + + context "when the page is referenced as a mention" do + let(:source) { :mention } + + it "renders no badge" do + expect(page).not_to have_test_selector("wiki-page-link-source-badge") + end + end + + context "when the page has no source" do + it "renders no badge" do + expect(page).not_to have_test_selector("wiki-page-link-source-badge") + end + end + context "when the page link has the remove action" do let(:actions) { [:remove] } diff --git a/modules/wikis/spec/components/wikis/page_link_view_model_spec.rb b/modules/wikis/spec/components/wikis/page_link_view_model_spec.rb new file mode 100644 index 000000000000..f8120ce6e3b7 --- /dev/null +++ b/modules/wikis/spec/components/wikis/page_link_view_model_spec.rb @@ -0,0 +1,116 @@ +# 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::PageLinkViewModel do + let(:provider) { build_stubbed(:internal_wiki_provider) } + let(:page_info) do + Wikis::Adapters::Results::PageInfo.new( + identifier: "42", + title: "Test page", + provider:, + href: "https://wiki.example.com/test" + ) + end + let(:failure_result) { Failure(Wikis::Adapters::Results::Error.new(source: nil, code: :not_found)) } + + describe ".from_reference" do + subject(:view_model) { described_class.from_reference(result) } + + context "when the result is a link" do + let(:result) { Success(Wikis::Adapters::Results::PageReference.new(page_info:, source: :link)) } + + it "extracts the page_info_result from the reference" do + expect(view_model.page_info_result).to be_success + expect(view_model.page_info_result.value!).to eq(page_info) + end + + it "carries the link source" do + expect(view_model.source).to eq(:link) + end + end + + context "when the result is a mention" do + let(:result) { Success(Wikis::Adapters::Results::PageReference.new(page_info:, source: :mention)) } + + it "extracts the page_info_result from the reference" do + expect(view_model.page_info_result).to be_success + expect(view_model.page_info_result.value!).to eq(page_info) + end + + it "carries the mention source" do + expect(view_model.source).to eq(:mention) + end + end + + context "when the result is a failure" do + let(:result) { failure_result } + + it "passes the failure through as page_info_result" do + expect(view_model.page_info_result).to be_failure + end + + it "carries no source" do + expect(view_model.source).to be_nil + end + end + end + + describe ".from_inline" do + subject(:view_model) { described_class.from_inline(result) } + + context "when the result is a success" do + let(:result) { Success(page_info) } + + it "passes the page_info_result through unchanged" do + expect(view_model.page_info_result).to be_success + expect(view_model.page_info_result.value!).to eq(page_info) + end + + it "carries no source" do + expect(view_model.source).to be_nil + end + end + + context "when the result is a failure" do + let(:result) { failure_result } + + it "passes the failure through as page_info_result" do + expect(view_model.page_info_result).to be_failure + end + + it "carries no source" do + expect(view_model.source).to be_nil + end + end + end +end diff --git a/modules/wikis/spec/services/wikis/adapters/providers/internal/queries/referencing_pages_query_spec.rb b/modules/wikis/spec/services/wikis/adapters/providers/internal/queries/referencing_pages_query_spec.rb index f8838b066d95..0a460b32017c 100644 --- a/modules/wikis/spec/services/wikis/adapters/providers/internal/queries/referencing_pages_query_spec.rb +++ b/modules/wikis/spec/services/wikis/adapters/providers/internal/queries/referencing_pages_query_spec.rb @@ -63,8 +63,9 @@ it "returns pages indicated by reverse links" do results = subject.value! expect(results).to all(be_success) - infos = results.map(&:value!) - expect(infos.map(&:title)).to contain_exactly(wiki_page.title) + page_references = results.map(&:value!) + expect(page_references.map { it.page_info.title }).to contain_exactly(wiki_page.title) + expect(page_references.map(&:source)).to all(eq(:link)) end context "when there are no reverse links" do diff --git a/modules/wikis/spec/services/wikis/adapters/providers/xwiki/queries/referencing_pages_spec.rb b/modules/wikis/spec/services/wikis/adapters/providers/xwiki/queries/referencing_pages_spec.rb index 165b2f523941..3475cf57dcdc 100644 --- a/modules/wikis/spec/services/wikis/adapters/providers/xwiki/queries/referencing_pages_spec.rb +++ b/modules/wikis/spec/services/wikis/adapters/providers/xwiki/queries/referencing_pages_spec.rb @@ -55,11 +55,13 @@ end let(:input_data) { Wikis::Adapters::Input::ReferencingPages.build(linkable:).value! } let(:query) { described_class.new(model: wiki_provider) } - let(:resolved_identifiers) { result.value!.map { it.value!.identifier } } + let(:resolved_page_references) { result.value!.map(&:value!) } + let(:resolved_identifiers) { resolved_page_references.map { it.page_info.identifier } } + let(:resolved_sources) { resolved_page_references.map(&:source) } subject(:result) { query.call(input_data:, auth_strategy:) } - context "when the same page appears multiple times in results" do + context "when the same page appears multiple times in link results" do let(:duplicate_id) { "xwiki:Main.WebHome" } let(:same_title_different_id) { "xwiki:Other.WebHome" } @@ -91,6 +93,36 @@ it "deduplicates by page identifier, not by title" do expect(resolved_identifiers).to contain_exactly("aaa111", "bbb222") end + + it "tags all results as :link" do + expect(resolved_sources).to all(eq(:link)) + end + end + + context "with mixed link and mention pages" do + let(:link_only_id) { "xwiki:Link.Only" } + let(:shared_id) { "xwiki:Shared.Page" } + let(:mention_only_id) { "xwiki:Mention.Only" } + + before do + stub_search([{ "id" => link_only_id }, { "id" => shared_id }], provider: wiki_provider, linkable:) + stub_mentions([{ "id" => shared_id }, { "id" => mention_only_id }], provider: wiki_provider, linkable:) + stub_canonical_page_info(link_only_id, uid: "aaa111", title: "Link Only", + href: "https://xwiki.example.com/link/", provider: wiki_provider) + stub_canonical_page_info(shared_id, uid: "bbb222", title: "Shared", + href: "https://xwiki.example.com/shared/", provider: wiki_provider) + stub_canonical_page_info(mention_only_id, uid: "ccc333", title: "Mention Only", + href: "https://xwiki.example.com/mention/", provider: wiki_provider) + end + + it "returns each page exactly once" do + expect(resolved_identifiers).to contain_exactly("aaa111", "bbb222", "ccc333") + end + + it "assigns sources correctly" do + sources_by_identifier = result.value!.to_h { [it.value!.page_info.identifier, it.value!.source] } + expect(sources_by_identifier).to eq("aaa111" => :link, "bbb222" => :link, "ccc333" => :mention) + end end context "with a single page" do @@ -113,6 +145,10 @@ it "returns the page only once" do expect(resolved_identifiers).to contain_exactly("aaa111") end + + it "tags the shared page as :link" do + expect(resolved_sources).to contain_exactly(:link) + end end context "when pages appear only in mentions" do @@ -124,6 +160,10 @@ it "includes pages from the mentions endpoint" do expect(resolved_identifiers).to contain_exactly("aaa111") end + + it "tags mention-only pages as :mention" do + expect(resolved_sources).to contain_exactly(:mention) + end end context "when the mentions request fails" do @@ -172,9 +212,9 @@ let(:linkable) { create(:work_package, id: 14) } let(:auth_strategy) { wiki_provider.auth_strategy_for(user).value! } - it "returns PageInfo for all linked and mentioned pages" do + it "returns PageReference for all linked and mentioned pages" do expect(result).to be_success - expect(result.value!.map { it.value!.to_h.except(:provider) }).to contain_exactly( + expect(result.value!.map { it.value!.page_info.to_h.except(:provider) }).to contain_exactly( { identifier: "48944", title: "OpenProject integration", href: "https://xwiki.local/bin/view/test/" }, { identifier: "42f2f", title: "Def New Page", href: "https://xwiki.local/bin/view/test/Def%20New%20Page/" }, { identifier: "a3739", title: "Just a normal page", href: "https://xwiki.local/bin/view/Just%20a%20normal%20page/" },