From 59e7d30e057999d3a3ce081f61b19e057041dbc7 Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Fri, 19 Jun 2026 16:04:06 +0200 Subject: [PATCH 1/7] Draft idea of how to show badges for the page links component --- .../collapsible_page_links_component.html.erb | 4 +- .../wikis/collapsible_page_links_component.rb | 11 ++++- .../wikis/page_link_component.html.erb | 19 ++++++-- .../components/wikis/page_link_component.rb | 7 ++- .../work_package_wikis_tab_component.html.erb | 3 +- .../wikis/work_package_wikis_tab_component.rb | 4 ++ .../wikis/adapters/results/page_info.rb | 6 ++- .../wikis/adapters/results/page_reference.rb | 36 ++++++++++++++ .../internal/queries/referencing_pages.rb | 5 +- .../xwiki/queries/referencing_pages.rb | 14 +++++- modules/wikis/config/locales/en.yml | 1 + .../queries/referencing_pages_query_spec.rb | 5 +- .../xwiki/queries/referencing_pages_spec.rb | 48 +++++++++++++++++-- 13 files changed, 143 insertions(+), 20 deletions(-) create mode 100644 modules/wikis/app/models/wikis/adapters/results/page_reference.rb 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..5136c4662ce0 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 |result| + box.with_row { render(Wikis::PageLinkComponent.new(page_info_result_for(result), badge: badge_for(result))) } end end %> diff --git a/modules/wikis/app/components/wikis/collapsible_page_links_component.rb b/modules/wikis/app/components/wikis/collapsible_page_links_component.rb index 51d51339952d..467428dd0945 100644 --- a/modules/wikis/app/components/wikis/collapsible_page_links_component.rb +++ b/modules/wikis/app/components/wikis/collapsible_page_links_component.rb @@ -37,10 +37,19 @@ class CollapsiblePageLinksComponent < ApplicationComponent alias_method :page_links, :model - def initialize(model = nil, heading:, **) + def initialize(model = nil, heading:, badge_for: nil, **) @heading = heading + @badge_for = badge_for super(model, **) end + + def page_info_result_for(result) + result.fmap(&:page_info) + end + + def badge_for(result) + @badge_for&.call(result.value!) if result.success? + end 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 30ea3e9cb6f8..6b684955680b 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,20 @@ 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)) { 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)) { page_title } + end + end + end + ) + concat(render(Primer::Beta::Label.new(scheme: :secondary)) { badge }) if badge.present? 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..b9ddbb84d633 100644 --- a/modules/wikis/app/components/wikis/page_link_component.rb +++ b/modules/wikis/app/components/wikis/page_link_component.rb @@ -37,13 +37,18 @@ class PageLinkComponent < ApplicationComponent attr_reader :actions - def initialize(model = nil, actions: [], page_link: nil, **) + def initialize(model = nil, actions: [], page_link: nil, badge: nil, **) @actions = actions @page_link = page_link + @badge = badge super(model, **) end + def badge + @badge + end + def page_title page_info_result.either( ->(pi) { pi.title }, diff --git a/modules/wikis/app/components/wikis/work_package_wikis_tab_component.html.erb b/modules/wikis/app/components/wikis/work_package_wikis_tab_component.html.erb index aae5c4a91932..126139ff962c 100644 --- a/modules/wikis/app/components/wikis/work_package_wikis_tab_component.html.erb +++ b/modules/wikis/app/components/wikis/work_package_wikis_tab_component.html.erb @@ -45,7 +45,8 @@ See COPYRIGHT and LICENSE files for more details. if referencing_wiki_pages.any? container.with_row do - render(::Wikis::CollapsiblePageLinksComponent.new(referencing_wiki_pages, heading: t(".referencing_pages"))) + render(::Wikis::CollapsiblePageLinksComponent.new(referencing_wiki_pages, heading: t(".referencing_pages"), + badge_for: referencing_page_badge_for)) 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..daadb9be4fee 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 @@ -54,6 +54,10 @@ def referencing_wiki_pages @referencing_wiki_pages ||= page_link_service.referencing_wiki_page_infos_for(linkable: work_package) end + def referencing_page_badge_for + ->(page_ref) { t(".badge_as_parent") if page_ref.source == :link } + end + private def page_link_service diff --git a/modules/wikis/app/models/wikis/adapters/results/page_info.rb b/modules/wikis/app/models/wikis/adapters/results/page_info.rb index e887846e2402..503b8d817fad 100644 --- a/modules/wikis/app/models/wikis/adapters/results/page_info.rb +++ b/modules/wikis/app/models/wikis/adapters/results/page_info.rb @@ -29,5 +29,9 @@ #++ module Wikis::Adapters::Results - PageInfo = Data.define(:identifier, :provider, :title, :href) + PageInfo = Data.define(:identifier, :provider, :title, :href) do + # Satisfies the same protocol as PageReference — allows callers to call + # .fmap(&:page_info) uniformly on Either and Either. + def page_info = self + end end 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..1c380fcc47c6 --- /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 structured WorkPackage 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..1f9e08501599 100644 --- a/modules/wikis/config/locales/en.yml +++ b/modules/wikis/config/locales/en.yml @@ -190,5 +190,6 @@ en: title: XWiki Integration work_package_wikis_tab_component: + badge_as_parent: As parent inline_page_links: Mentioned in description referencing_pages: Referenced in 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/" }, From 32a781e06ae210c59660832340fd6350cfa08a7b Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Fri, 19 Jun 2026 16:42:32 +0200 Subject: [PATCH 2/7] Add representer to encapsulate the badge logic --- .../collapsible_page_links_component.html.erb | 4 +- .../wikis/collapsible_page_links_component.rb | 11 +---- .../wikis/inline_page_link_presenter.rb | 47 ++++++++++++++++++ .../wikis/page_reference_presenter.rb | 49 +++++++++++++++++++ .../work_package_wikis_tab_component.html.erb | 3 +- .../wikis/work_package_wikis_tab_component.rb | 6 +-- .../wikis/adapters/results/page_info.rb | 6 +-- modules/wikis/config/locales/en.yml | 3 +- 8 files changed, 105 insertions(+), 24 deletions(-) create mode 100644 modules/wikis/app/components/wikis/inline_page_link_presenter.rb create mode 100644 modules/wikis/app/components/wikis/page_reference_presenter.rb 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 5136c4662ce0..1b9d2dbfcb7e 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 |result| - box.with_row { render(Wikis::PageLinkComponent.new(page_info_result_for(result), badge: badge_for(result))) } + page_links.each do |presenter| + box.with_row { render(Wikis::PageLinkComponent.new(presenter.page_info_result, badge: presenter.badge)) } end end %> diff --git a/modules/wikis/app/components/wikis/collapsible_page_links_component.rb b/modules/wikis/app/components/wikis/collapsible_page_links_component.rb index 467428dd0945..51d51339952d 100644 --- a/modules/wikis/app/components/wikis/collapsible_page_links_component.rb +++ b/modules/wikis/app/components/wikis/collapsible_page_links_component.rb @@ -37,19 +37,10 @@ class CollapsiblePageLinksComponent < ApplicationComponent alias_method :page_links, :model - def initialize(model = nil, heading:, badge_for: nil, **) + def initialize(model = nil, heading:, **) @heading = heading - @badge_for = badge_for super(model, **) end - - def page_info_result_for(result) - result.fmap(&:page_info) - end - - def badge_for(result) - @badge_for&.call(result.value!) if result.success? - end end end diff --git a/modules/wikis/app/components/wikis/inline_page_link_presenter.rb b/modules/wikis/app/components/wikis/inline_page_link_presenter.rb new file mode 100644 index 000000000000..765b93536855 --- /dev/null +++ b/modules/wikis/app/components/wikis/inline_page_link_presenter.rb @@ -0,0 +1,47 @@ +# 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 + # Presenter for Either items in the "Mentioned in description" section. + # Implements the page link presenter interface: #page_info_result and #badge. + class InlinePageLinkPresenter + def initialize(result) + @result = result + end + + def page_info_result + @result + end + + def badge + nil + end + end +end diff --git a/modules/wikis/app/components/wikis/page_reference_presenter.rb b/modules/wikis/app/components/wikis/page_reference_presenter.rb new file mode 100644 index 000000000000..aa73a6df0281 --- /dev/null +++ b/modules/wikis/app/components/wikis/page_reference_presenter.rb @@ -0,0 +1,49 @@ +# 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 + # Presenter for Either items in the "Referenced in" section. + # Implements the page link presenter interface: #page_info_result and #badge. + class PageReferencePresenter + def initialize(result) + @result = result + end + + def page_info_result + @result.fmap(&:page_info) + end + + def badge + return unless @result.success? && @result.value!.source == :link + + I18n.t("wikis.page_links.source.link") + end + end +end diff --git a/modules/wikis/app/components/wikis/work_package_wikis_tab_component.html.erb b/modules/wikis/app/components/wikis/work_package_wikis_tab_component.html.erb index 126139ff962c..aae5c4a91932 100644 --- a/modules/wikis/app/components/wikis/work_package_wikis_tab_component.html.erb +++ b/modules/wikis/app/components/wikis/work_package_wikis_tab_component.html.erb @@ -45,8 +45,7 @@ See COPYRIGHT and LICENSE files for more details. if referencing_wiki_pages.any? container.with_row do - render(::Wikis::CollapsiblePageLinksComponent.new(referencing_wiki_pages, heading: t(".referencing_pages"), - badge_for: referencing_page_badge_for)) + render(::Wikis::CollapsiblePageLinksComponent.new(referencing_wiki_pages, heading: t(".referencing_pages"))) 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 daadb9be4fee..ab70319a0877 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,14 +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 { InlinePageLinkPresenter.new(it) } end def referencing_wiki_pages @referencing_wiki_pages ||= page_link_service.referencing_wiki_page_infos_for(linkable: work_package) - end - - def referencing_page_badge_for - ->(page_ref) { t(".badge_as_parent") if page_ref.source == :link } + .map { PageReferencePresenter.new(it) } end private diff --git a/modules/wikis/app/models/wikis/adapters/results/page_info.rb b/modules/wikis/app/models/wikis/adapters/results/page_info.rb index 503b8d817fad..e887846e2402 100644 --- a/modules/wikis/app/models/wikis/adapters/results/page_info.rb +++ b/modules/wikis/app/models/wikis/adapters/results/page_info.rb @@ -29,9 +29,5 @@ #++ module Wikis::Adapters::Results - PageInfo = Data.define(:identifier, :provider, :title, :href) do - # Satisfies the same protocol as PageReference — allows callers to call - # .fmap(&:page_info) uniformly on Either and Either. - def page_info = self - end + PageInfo = Data.define(:identifier, :provider, :title, :href) end diff --git a/modules/wikis/config/locales/en.yml b/modules/wikis/config/locales/en.yml index 1f9e08501599..4075edfbdeb6 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: + link: Link provider_types: xwiki: name: XWiki @@ -190,6 +192,5 @@ en: title: XWiki Integration work_package_wikis_tab_component: - badge_as_parent: As parent inline_page_links: Mentioned in description referencing_pages: Referenced in From 6f1858ab66d5f29a57ffaa8c08c34f972cd94354 Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Fri, 19 Jun 2026 16:48:59 +0200 Subject: [PATCH 3/7] Drop the presenter idea and went with view model --- .../collapsible_page_links_component.html.erb | 4 +- ...k_presenter.rb => page_link_view_model.rb} | 19 +++---- .../wikis/page_reference_presenter.rb | 49 ------------------- .../wikis/work_package_wikis_tab_component.rb | 4 +- 4 files changed, 12 insertions(+), 64 deletions(-) rename modules/wikis/app/components/wikis/{inline_page_link_presenter.rb => page_link_view_model.rb} (72%) delete mode 100644 modules/wikis/app/components/wikis/page_reference_presenter.rb 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 1b9d2dbfcb7e..424620b3f0d7 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 |presenter| - box.with_row { render(Wikis::PageLinkComponent.new(presenter.page_info_result, badge: presenter.badge)) } + page_links.each do |view_model| + box.with_row { render(Wikis::PageLinkComponent.new(view_model.page_info_result, badge: view_model.badge)) } end end %> diff --git a/modules/wikis/app/components/wikis/inline_page_link_presenter.rb b/modules/wikis/app/components/wikis/page_link_view_model.rb similarity index 72% rename from modules/wikis/app/components/wikis/inline_page_link_presenter.rb rename to modules/wikis/app/components/wikis/page_link_view_model.rb index 765b93536855..7c229f5a83f4 100644 --- a/modules/wikis/app/components/wikis/inline_page_link_presenter.rb +++ b/modules/wikis/app/components/wikis/page_link_view_model.rb @@ -29,19 +29,16 @@ #++ module Wikis - # Presenter for Either items in the "Mentioned in description" section. - # Implements the page link presenter interface: #page_info_result and #badge. - class InlinePageLinkPresenter - def initialize(result) - @result = result + # View model for a single row in a CollapsiblePageLinksComponent. + # Factory methods encapsulate how each domain type maps to display data. + PageLinkViewModel = Data.define(:page_info_result, :badge) do + def self.from_reference(result) + badge = I18n.t("wikis.page_links.source.link") if result.success? && result.value!.source == :link + new(page_info_result: result.fmap(&:page_info), badge:) end - def page_info_result - @result - end - - def badge - nil + def self.from_inline(result) + new(page_info_result: result, badge: nil) end end end diff --git a/modules/wikis/app/components/wikis/page_reference_presenter.rb b/modules/wikis/app/components/wikis/page_reference_presenter.rb deleted file mode 100644 index aa73a6df0281..000000000000 --- a/modules/wikis/app/components/wikis/page_reference_presenter.rb +++ /dev/null @@ -1,49 +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 Wikis - # Presenter for Either items in the "Referenced in" section. - # Implements the page link presenter interface: #page_info_result and #badge. - class PageReferencePresenter - def initialize(result) - @result = result - end - - def page_info_result - @result.fmap(&:page_info) - end - - def badge - return unless @result.success? && @result.value!.source == :link - - I18n.t("wikis.page_links.source.link") - 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 ab70319a0877..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,12 +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 { InlinePageLinkPresenter.new(it) } + .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 { PageReferencePresenter.new(it) } + .map { PageLinkViewModel.from_reference(it) } end private From 19b9bf4000365f0259036fabff0938a0863ccb03 Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Fri, 19 Jun 2026 17:07:51 +0200 Subject: [PATCH 4/7] =?UTF-8?q?Rename=20`badge`=20=E2=86=92=20`source`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wikis/collapsible_page_links_component.html.erb | 2 +- .../app/components/wikis/page_link_component.html.erb | 2 +- .../wikis/app/components/wikis/page_link_component.rb | 10 +++++----- .../app/components/wikis/page_link_view_model.rb | 11 ++++++----- modules/wikis/config/locales/en.yml | 2 +- 5 files changed, 14 insertions(+), 13 deletions(-) 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 424620b3f0d7..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 @@ -37,7 +37,7 @@ See COPYRIGHT and LICENSE files for more details. end page_links.each do |view_model| - box.with_row { render(Wikis::PageLinkComponent.new(view_model.page_info_result, badge: view_model.badge)) } + 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 6b684955680b..5c9c32bea6d5 100644 --- a/modules/wikis/app/components/wikis/page_link_component.html.erb +++ b/modules/wikis/app/components/wikis/page_link_component.html.erb @@ -50,7 +50,7 @@ See COPYRIGHT and LICENSE files for more details. end end ) - concat(render(Primer::Beta::Label.new(scheme: :secondary)) { badge }) if badge.present? + concat(render(Primer::Beta::Label.new(scheme: :secondary)) { badge_label }) if badge_label.present? 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 b9ddbb84d633..12ac567576f7 100644 --- a/modules/wikis/app/components/wikis/page_link_component.rb +++ b/modules/wikis/app/components/wikis/page_link_component.rb @@ -35,18 +35,18 @@ class PageLinkComponent < ApplicationComponent alias_method :page_info_result, :model - attr_reader :actions + attr_reader :actions, :source - def initialize(model = nil, actions: [], page_link: nil, badge: nil, **) + def initialize(model = nil, actions: [], page_link: nil, source: nil, **) @actions = actions @page_link = page_link - @badge = badge + @source = source super(model, **) end - def badge - @badge + def badge_label + I18n.t("wikis.page_links.source.parent") if source == :link end def page_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 index 7c229f5a83f4..bbe41abcf479 100644 --- a/modules/wikis/app/components/wikis/page_link_view_model.rb +++ b/modules/wikis/app/components/wikis/page_link_view_model.rb @@ -30,15 +30,16 @@ module Wikis # View model for a single row in a CollapsiblePageLinksComponent. - # Factory methods encapsulate how each domain type maps to display data. - PageLinkViewModel = Data.define(:page_info_result, :badge) do + # Factory methods normalize each domain result into a common shape: + # a page_info_result plus the source that produced it (nil for inline links). + # Mapping source -> badge label and styling is the component's concern. + PageLinkViewModel = Data.define(:page_info_result, :source) do def self.from_reference(result) - badge = I18n.t("wikis.page_links.source.link") if result.success? && result.value!.source == :link - new(page_info_result: result.fmap(&:page_info), badge:) + 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, badge: nil) + new(page_info_result: result, source: nil) end end end diff --git a/modules/wikis/config/locales/en.yml b/modules/wikis/config/locales/en.yml index 4075edfbdeb6..21ca85393b77 100644 --- a/modules/wikis/config/locales/en.yml +++ b/modules/wikis/config/locales/en.yml @@ -176,7 +176,7 @@ en: page_not_found: Linked wiki page no longer available unexpected: An unexpected error occurred source: - link: Link + parent: As parent provider_types: xwiki: name: XWiki From 720ab0c4b1fa3f17fab5bc4ed7daf251d6b14200 Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Mon, 22 Jun 2026 18:23:24 +0200 Subject: [PATCH 5/7] Add specs --- .../wikis/page_link_component_spec.rb | 25 +++- .../wikis/page_link_view_model_spec.rb | 116 ++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 modules/wikis/spec/components/wikis/page_link_view_model_spec.rb 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..4fdfdbca1581 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,28 @@ 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_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).to have_no_text(I18n.t("wikis.page_links.source.parent")) + end + end + + context "when the page has no source" do + it "renders no badge" do + expect(page).to have_no_text(I18n.t("wikis.page_links.source.parent")) + 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 From e9c4a3a782be996b8b58e8067c3fe5d72d0ced65 Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Tue, 23 Jun 2026 15:26:26 +0200 Subject: [PATCH 6/7] =?UTF-8?q?=E2=9C=A8=20Refactor=20=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/components/wikis/page_link_component.html.erb | 4 +++- .../spec/components/wikis/page_link_component_spec.rb | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) 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 5c9c32bea6d5..f4dac8cb5e4c 100644 --- a/modules/wikis/app/components/wikis/page_link_component.html.erb +++ b/modules/wikis/app/components/wikis/page_link_component.html.erb @@ -50,7 +50,9 @@ See COPYRIGHT and LICENSE files for more details. end end ) - concat(render(Primer::Beta::Label.new(scheme: :secondary)) { badge_label }) if badge_label.present? + 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/spec/components/wikis/page_link_component_spec.rb b/modules/wikis/spec/components/wikis/page_link_component_spec.rb index 4fdfdbca1581..62b5029b515a 100644 --- a/modules/wikis/spec/components/wikis/page_link_component_spec.rb +++ b/modules/wikis/spec/components/wikis/page_link_component_spec.rb @@ -63,7 +63,8 @@ let(:source) { :link } it "renders the parent badge" do - expect(page).to have_text(I18n.t("wikis.page_links.source.parent")) + expect(page).to have_test_selector("wiki-page-link-source-badge", + text: I18n.t("wikis.page_links.source.parent")) end end @@ -71,13 +72,13 @@ let(:source) { :mention } it "renders no badge" do - expect(page).to have_no_text(I18n.t("wikis.page_links.source.parent")) + 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).to have_no_text(I18n.t("wikis.page_links.source.parent")) + expect(page).not_to have_test_selector("wiki-page-link-source-badge") end end From 5f98dbaf98e2baac518c3b1324c27f8f68b55383 Mon Sep 17 00:00:00 2001 From: Yauheni Suhakou Date: Wed, 24 Jun 2026 15:23:46 +0200 Subject: [PATCH 7/7] =?UTF-8?q?Clean=20up=20comments=20=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/wikis/app/components/wikis/page_link_view_model.rb | 2 -- .../wikis/app/models/wikis/adapters/results/page_reference.rb | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/modules/wikis/app/components/wikis/page_link_view_model.rb b/modules/wikis/app/components/wikis/page_link_view_model.rb index bbe41abcf479..d9d83e006058 100644 --- a/modules/wikis/app/components/wikis/page_link_view_model.rb +++ b/modules/wikis/app/components/wikis/page_link_view_model.rb @@ -30,8 +30,6 @@ module Wikis # View model for a single row in a CollapsiblePageLinksComponent. - # Factory methods normalize each domain result into a common shape: - # a page_info_result plus the source that produced it (nil for inline links). # Mapping source -> badge label and styling is the component's concern. PageLinkViewModel = Data.define(:page_info_result, :source) do def self.from_reference(result) diff --git a/modules/wikis/app/models/wikis/adapters/results/page_reference.rb b/modules/wikis/app/models/wikis/adapters/results/page_reference.rb index 1c380fcc47c6..7883cbe0ed17 100644 --- a/modules/wikis/app/models/wikis/adapters/results/page_reference.rb +++ b/modules/wikis/app/models/wikis/adapters/results/page_reference.rb @@ -30,7 +30,7 @@ module Wikis::Adapters::Results # Pairs a PageInfo with how it relates to a given work package. - # source: :link — page has a structured WorkPackage link + # 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