Merge pull request #23236 from opf/create-wiki-link-dialog

Create page link UI
This commit is contained in:
Jan Sandbrink
2026-05-29 12:39:34 +02:00
committed by GitHub
20 changed files with 269 additions and 47 deletions
+2
View File
@@ -85,6 +85,8 @@ module Authorization
if perms.blank?
if !OpenProject::AccessControl.disabled_permission?(action)
# See https://www.openproject.org/docs/development/concepts/permissions/#definition-of-permissions
# if you are wondering where to define permissions
Rails.logger.debug { "Used permission \"#{action}\" that is not defined. It will never return true." }
raise UnknownPermissionError.new(action) if raise_on_unknown
end
@@ -0,0 +1,40 @@
<%#-- 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.
++#%>
<%= render(Primer::Alpha::Dialog.new(id:, title: t(".title"), size: :large, **system_arguments)) do |dialog| %>
<% dialog.with_body do
primer_form_with(**form_options) do |form|
render(Wikis::LinkExistingWikiPageForm.new(form))
end
end %>
<% dialog.with_footer do %>
<%= render(Primer::Beta::Button.new(data: { "close-dialog-id": id })) { t("button_cancel") } %>
<%= render(Primer::Beta::Button.new(scheme: :primary, form: form_id, type: :submit)) { t("button_add") } %>
<% end %>
<% end %>
@@ -0,0 +1,63 @@
# 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 LinkExistingWikiPageDialog < ApplicationComponent
include OpTurbo::Streamable
attr_reader :linkable, :provider
def initialize(linkable:, provider:, **)
super(nil, **)
@linkable = linkable
@provider = provider
end
def id = "link-existing-wiki-page-dialog"
def form_id = "#{id}-form"
def form_options
{
id: form_id,
model: RelationPageLink.new(provider:, linkable:),
url: relation_wiki_page_links_path,
data: {
turbo_frame: WorkPackageWikisTabComponent::TURBO_FRAME_ID
}
}
end
def system_arguments
options
end
end
end
@@ -37,14 +37,23 @@ See COPYRIGHT and LICENSE files for more details.
end
header.with_column do
render(Primer::Alpha::ActionMenu.new) do |menu|
menu.with_show_button(disabled: true) do |button|
menu.with_show_button(disabled: !can_manage_links?) do |button|
button.with_leading_visual_icon(icon: :plus)
button.with_trailing_action_icon(icon: :"triangle-down")
t("wikis.buttons.wiki_page")
end
menu.with_item(label: "placeholder item")
menu.with_item(
label: t(".link_existing"),
tag: :a,
href: link_existing_dialog_relation_wiki_page_links_path(work_package:, provider:),
content_arguments: { data: { controller: "async-dialog" } }
)
menu.with_item(
label: t(".link_new"),
disabled: true # work in progress
)
end
end
end
@@ -52,7 +61,7 @@ See COPYRIGHT and LICENSE files for more details.
if !user_connected?
box.with_row do
render(Wikis::OAuthLoginComponent.new(provider, return_url: work_package_url(@work_package, tab: :wikis)))
render(Wikis::OAuthLoginComponent.new(provider, return_url: work_package_url(work_package, tab: :wikis)))
end
elsif page_links.empty?
box.with_row do
@@ -62,7 +71,7 @@ See COPYRIGHT and LICENSE files for more details.
blankslate.with_description { t(".empty_text") }
end
else
render(Wikis::OAuthLoginComponent.new(provider, return_url: work_package_url(@work_package, tab: :wikis)))
render(Wikis::OAuthLoginComponent.new(provider, return_url: work_package_url(work_package, tab: :wikis)))
end
end
else
@@ -35,13 +35,15 @@ module Wikis
alias_method :provider, :model
attr_reader :work_package
def initialize(model = nil, work_package: nil, **)
@work_package = work_package
super(model, **)
end
def page_links
@page_links ||= page_link_service.relation_page_links_for(provider:, linkable: @work_package)
@page_links ||= page_link_service.relation_page_links_for(provider:, linkable: work_package)
end
def user_connected?
@@ -53,5 +55,9 @@ module Wikis
def page_link_service
@page_link_service ||= PageLinkService.new
end
def can_manage_links?
helpers.current_user.allowed_in_project?(:manage_wiki_page_links, work_package.project)
end
end
end
@@ -28,7 +28,7 @@ See COPYRIGHT and LICENSE files for more details.
++#%>
<%=
content_tag("turbo-frame", id: "work-package-wikis-tab-content") do
content_tag("turbo-frame", id: TURBO_FRAME_ID) do
component_wrapper do
flex_layout(test_selector: "op-work-package-wikis-tab-container") do |container|
providers.each do |provider|
@@ -34,6 +34,8 @@ module Wikis
include OpPrimer::ComponentHelpers
include OpTurbo::Streamable
TURBO_FRAME_ID = "work-package-wikis-tab-content"
alias_method :work_package, :model
def providers
@@ -29,16 +29,20 @@
#++
module Wikis
module PageLinks
class RelationPageLinkCreateContract < ::ModelContract
module RelationPageLinks
class CreateContract < ::ModelContract
attribute :author
attribute :identifier
attribute :linkable
attribute :linkable_type
attribute :linkable_id
attribute :provider
attribute :type
validates :identifier, presence: true
validates :linkable, presence: true
validates :linkable_type, presence: true
validates :linkable_id, presence: true
validates :provider, presence: true
validates :type, inclusion: { in: [RelationPageLink.name] }
validate :provider_exists?
validate :author_must_be_user
@@ -32,21 +32,62 @@ module Wikis
class RelationPageLinksController < ApplicationController
include OpTurbo::ComponentStream
before_action :find_page_link
before_action :authorize
def create
service_result = RelationPageLinks::CreateService.new(user: current_user).call(relation_page_link_params)
if service_result.success?
page_link = service_result.result
turbo_redirect_for_linkable(page_link.linkable)
else
message = service_result.errors.full_messages.join(" ")
render_error_flash_message_via_turbo_stream(message:)
respond_to_with_turbo_streams
end
end
def destroy
# TODO: implement delete service
# TODO: Wikis::PageLinks::DeleteService
page_link = find_page_link
page_link.destroy!
turbo_redirect_for_linkable(page_link.linkable)
end
def confirm_delete_dialog
respond_with_dialog(DeleteRelationPageLinkConfirmationDialog.new(page_link: @page_link))
page_link = find_page_link
respond_with_dialog(DeleteRelationPageLinkConfirmationDialog.new(page_link:))
end
def link_existing_dialog
linkable = WorkPackage.visible.find(params.expect(:work_package))
provider = Provider.visible.find(params.expect(:provider))
respond_with_dialog Wikis::LinkExistingWikiPageDialog.new(linkable:, provider:)
end
private
def find_page_link
@page_link = RelationPageLink.find(params.expect(:id))
RelationPageLink.find(params.expect(:id))
end
def relation_page_link_params
params.expect(wikis_relation_page_link: %i[identifier provider_id linkable_type linkable_id])
.merge(author_id: current_user.id)
end
def turbo_redirect_for_linkable(linkable)
path = derive_path_from_linkable(linkable)
return redirect_to path, status: :see_other if path
head :no_content
end
def derive_path_from_linkable(linkable)
case linkable
when WorkPackage
project_work_package_wikis_tab_index_path(work_package_id: linkable.id, project_id: linkable.project_id)
end
end
end
end
@@ -36,7 +36,14 @@ class WorkPackageWikisTabController < ApplicationController
before_action :set_work_package
def index
render(Wikis::WorkPackageWikisTabComponent.new(@work_package), layout: false)
tab_component = Wikis::WorkPackageWikisTabComponent.new(@work_package)
replace_via_turbo_stream(component: tab_component)
respond_to_with_turbo_streams do |format|
format.html do
render(tab_component, layout: false)
end
end
end
private
@@ -33,7 +33,7 @@ module Wikis::Admin
form do |f|
f.text_field(
name: :name,
label: I18n.t("activerecord.attributes.wikis/xwiki_provider.name"),
label: model.class.human_attribute_name(:name),
required: true,
caption: I18n.t("wikis.admin.wiki_providers.name_caption"),
placeholder: I18n.t("wikis.admin.wiki_providers.name_placeholder"),
@@ -0,0 +1,46 @@
# 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 LinkExistingWikiPageForm < ApplicationForm
form do |f|
f.hidden(name: :provider_id)
f.hidden(name: :linkable_type)
f.hidden(name: :linkable_id)
f.text_field(
name: :identifier,
label: RelationPageLink.human_attribute_name(:identifier),
required: true,
input_width: :large
)
end
end
end
@@ -29,11 +29,8 @@
#++
module Wikis
module PageLinks
module RelationPageLinks
class CreateService < ::BaseServices::Create
private
def default_contract_class = RelationPageLinkCreateContract
end
end
end
@@ -29,7 +29,7 @@
#++
module Wikis
module PageLinks
module RelationPageLinks
class SetAttributesService < ::BaseServices::SetAttributes
end
end
+8 -2
View File
@@ -3,15 +3,17 @@ en:
activerecord:
attributes:
wikis/page_link:
identifier: Identifier
provider: Wiki Provider
wikis/provider:
name: Name
universal_identifier: Universal identifier
wikis/xwiki_provider:
authentication_method: Authentication method
authentication_methods:
oauth2_sso: Single-Sign-On through OpenID Connect Identity Provider
two_way_oauth2: Two-way OAuth 2.0 authorization code flow
name: Name
token_exchange_scope: XWiki Scope
universal_identifier: Universal identifier
url: Instance URL
wiki_audience: XWiki Audience
errors: { }
@@ -65,6 +67,8 @@ en:
work_package_wikis_tab_component:
inline_page_links: Inline page links
referencing_pages: Referenced in
link_existing_wiki_page_dialog:
title: Add existing wiki page
page_links:
errors:
page_not_found: Linked wiki page no longer available
@@ -73,6 +77,8 @@ en:
page_link_component:
remove: Remove page link
relation_page_links_component:
link_existing: Existing wiki page
link_new: New wiki page
empty_heading: No related pages
empty_text: Manually add links to other related wiki pages.
oauth_login_component:
+14 -10
View File
@@ -49,16 +49,6 @@ Rails.application.routes.draw do
end
end
resource :wiki_page_link_macro, controller: "wikis/page_link_macro", only: [] do
get :load
end
resources :relation_wiki_page_links, only: %i[destroy], controller: "wikis/relation_page_links" do
member do
get :confirm_delete_dialog
end
end
resources :projects, only: %i[] do
resources :work_packages, only: %i[] do
resources :wikis, only: %i[] do
@@ -68,4 +58,18 @@ Rails.application.routes.draw do
end
end
end
resources :relation_wiki_page_links, only: %i[create destroy], controller: "wikis/relation_page_links" do
collection do
get :link_existing_dialog
end
member do
get :confirm_delete_dialog
end
end
resource :wiki_page_link_macro, controller: "wikis/page_link_macro", only: [] do
get :load
end
end
@@ -72,7 +72,9 @@ module OpenProject::Wikis
register "openproject-wikis", author_url: "https://openproject.org" do
project_module :work_package_tracking do
permission :manage_wiki_page_links,
{ "wikis/relation_page_links": %i[destroy confirm_delete_dialog] },
{
"wikis/relation_page_links": %i[create destroy confirm_delete_dialog link_existing_dialog]
},
permissible_on: :project,
dependencies: %i[edit_work_packages],
contract_actions: { wiki_page_links: %i[manage] }
@@ -33,8 +33,8 @@ require "contracts/shared/model_contract_shared_context"
require_module_spec_helper
module Wikis
module PageLinks
RSpec.describe RelationPageLinkCreateContract do
module RelationPageLinks
RSpec.describe CreateContract do
include_context "ModelContract shared context"
let(:linkable) { create(:work_package) }
let(:project) { linkable.project }
@@ -34,18 +34,11 @@ require "services/base_services/behaves_like_create_service"
require_module_spec_helper
module Wikis
module PageLinks
module RelationPageLinks
RSpec.describe CreateService do
it_behaves_like "BaseServices create service" do
let(:contract_class) { RelationPageLinkCreateContract }
let(:factory) { :relation_wiki_page_link }
end
it "defaults to the RelationPageLinkCreateContract" do
service = described_class.new(user: nil)
expect(service.contract_class).to eq(RelationPageLinkCreateContract)
end
end
end
end
@@ -32,15 +32,15 @@ require "spec_helper"
require_module_spec_helper
module Wikis
module PageLinks
module RelationPageLinks
RSpec.describe SetAttributesService do
let(:model_instance) { RelationPageLink.new }
let(:contract_instance) do
instance_double(RelationPageLinkCreateContract, validate: contract_valid, errors: contract_errors)
instance_double(CreateContract, validate: contract_valid, errors: contract_errors)
end
let(:contract_class) do
class_double(RelationPageLinkCreateContract, new: contract_instance)
class_double(CreateContract, new: contract_instance)
end
let(:contract_errors) { instance_double(ActiveModel::Errors) }