diff --git a/app/components/projects/index_page_header_component.html.erb b/app/components/projects/index_page_header_component.html.erb
index fd16bd2637d3..e9a98772215c 100644
--- a/app/components/projects/index_page_header_component.html.erb
+++ b/app/components/projects/index_page_header_component.html.erb
@@ -1,7 +1,7 @@
<% if show_state? %>
<%=
render(Primer::OpenProject::PageHeader.new) do |header|
- header.with_title(data: { 'test-selector': 'project-query-name'}) { page_title }
+ header.with_title(data: { 'test-selector': 'project-query-name' }) { page_title }
header.with_breadcrumbs(breadcrumb_items, selected_item_font_weight: current_breadcrumb_element == page_title ? :bold : :normal)
if can_save?
@@ -21,6 +21,17 @@
)
end
+ if can_access_shares?
+ header.with_action_icon_button(
+ tag: :a,
+ href: dialog_project_query_members_path(query),
+ icon: "share-android",
+ mobile_icon: "share-android",
+ label: t(:label_share),
+ data: { controller: "async-dialog" }
+ )
+ end
+
if can_toggle_favor?
if currently_favored?
header.with_action_icon_button(
@@ -115,29 +126,6 @@
end
if query.persisted?
- # TODO: Remove section when the sharing modal is implemented (https://community.openproject.org/projects/openproject/work_packages/55163)
- if can_publish?
- if query.public?
- menu.with_item(
- label: t(:button_unpublish),
- scheme: :danger,
- href: unpublish_project_query_path(query),
- content_arguments: { data: { method: :post } }
- ) do |item|
- item.with_leading_visual_icon(icon: 'eye-closed')
- end
- else
- menu.with_item(
- label: t(:button_publish),
- scheme: :default,
- href: publish_project_query_path(query),
- content_arguments: { data: { method: :post } }
- ) do |item|
- item.with_leading_visual_icon(icon: 'eye')
- end
- end
- end
-
menu.with_item(
label: t(:button_delete),
scheme: :danger,
@@ -155,7 +143,7 @@
<% else %>
<%=
render(Primer::OpenProject::PageHeader.new) do |header|
- header.with_title(data: { 'test-selector': 'project-query-name'}) do
+ header.with_title(data: { 'test-selector': 'project-query-name' }) do
primer_form_with(model: query,
url: @query.new_record? ? project_queries_path(projects_query_params) : project_query_path(@query, projects_query_params),
scope: 'query',
diff --git a/app/components/projects/index_page_header_component.rb b/app/components/projects/index_page_header_component.rb
index fadd93b97d9c..3754115e832d 100644
--- a/app/components/projects/index_page_header_component.rb
+++ b/app/components/projects/index_page_header_component.rb
@@ -77,11 +77,7 @@ def can_save?
return false unless query.persisted?
return false unless query.changed?
- if query.public?
- current_user.allowed_globally?(:manage_public_project_queries)
- else
- query.user == current_user
- end
+ query.editable?
end
def can_rename?
@@ -89,23 +85,17 @@ def can_rename?
return false unless query.persisted?
return false if query.changed?
- if query.public?
- current_user.allowed_globally?(:manage_public_project_queries)
- else
- query.user == current_user
- end
- end
-
- def can_publish?
- OpenProject::FeatureDecisions.project_list_sharing_active? &&
- current_user.allowed_globally?(:manage_public_project_queries) &&
- query.persisted?
+ query.editable?
end
def show_state?
state == :show
end
+ def can_access_shares?
+ query.persisted? && OpenProject::FeatureDecisions.project_list_sharing_active?
+ end
+
def can_toggle_favor? = query.persisted?
def currently_favored? = query.favored_by?(current_user)
diff --git a/app/components/shares/bulk_permission_button_component.rb b/app/components/shares/bulk_permission_button_component.rb
index f9a89e186737..b42c5de1dad5 100644
--- a/app/components/shares/bulk_permission_button_component.rb
+++ b/app/components/shares/bulk_permission_button_component.rb
@@ -30,11 +30,11 @@
module Shares
class BulkPermissionButtonComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent
- def initialize(entity:, available_roles:)
+ def initialize(strategy:)
super
- @entity = entity
- @available_roles = available_roles
+ @entity = strategy.entity
+ @available_roles = strategy.available_roles
end
def update_path
diff --git a/app/components/shares/counter_component.html.erb b/app/components/shares/counter_component.html.erb
index e9281848a582..2e58b8d310de 100644
--- a/app/components/shares/counter_component.html.erb
+++ b/app/components/shares/counter_component.html.erb
@@ -4,7 +4,7 @@
# There's no point in rendering the BulkSelectionCounterComponent even if
# I'm able to manage shares if the only user that the work package is
# currently shared is myself, since I'm not able to manage my own share.
- if sharing_manageable? && shared_with_anyone_else_other_than_myself?
+ if strategy.manageable? && shared_with_anyone_else_other_than_myself?
render(Shares::BulkSelectionCounterComponent.new(count:))
else
render(Shares::ShareCounterComponent.new(count:))
diff --git a/app/components/shares/counter_component.rb b/app/components/shares/counter_component.rb
index 145ee3b8c530..c2581bfb5dba 100644
--- a/app/components/shares/counter_component.rb
+++ b/app/components/shares/counter_component.rb
@@ -34,26 +34,22 @@ class CounterComponent < ApplicationComponent # rubocop:disable OpenProject/AddP
include OpTurbo::Streamable
include OpPrimer::ComponentHelpers
- def initialize(entity:,
- count:,
- sharing_manageable:)
+ def initialize(strategy:, count:)
super
- @entity = entity
+ @strategy = strategy
+ @entity = strategy.entity
@count = count
- @sharing_manageable = sharing_manageable
end
private
- attr_reader :entity, :count
-
- def sharing_manageable? = @sharing_manageable
+ attr_reader :entity, :count, :strategy
def shared_with_anyone_else_other_than_myself?
Member.of_entity(@entity)
.where.not(principal: User.current)
- .any?
+ .exists?
end
end
end
diff --git a/app/components/shares/empty_state_component.html.erb b/app/components/shares/empty_state_component.html.erb
new file mode 100644
index 000000000000..64e117e461b4
--- /dev/null
+++ b/app/components/shares/empty_state_component.html.erb
@@ -0,0 +1,15 @@
+<%= render(Primer::Beta::Blankslate.new) do |component|
+ component.with_visual_icon(icon: blankslate_config[:icon], size: :medium)
+ component.with_heading(tag: :h2).with_content(
+ blankslate_config[:heading_text],
+ )
+ component.with_description do
+ flex_layout do |flex|
+ flex.with_row(mb: 2) do
+ render(Primer::Beta::Text.new(color: :subtle)) do
+ blankslate_config[:description_text]
+ end
+ end
+ end
+ end
+end %>
diff --git a/app/components/shares/empty_state_component.rb b/app/components/shares/empty_state_component.rb
new file mode 100644
index 000000000000..1dda0facba91
--- /dev/null
+++ b/app/components/shares/empty_state_component.rb
@@ -0,0 +1,68 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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 Shares
+ class EmptyStateComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent
+ include OpPrimer::ComponentHelpers
+
+ def initialize(strategy:)
+ super
+
+ @strategy = strategy
+ @entity = strategy.entity
+ end
+
+ private
+
+ attr_reader :strategy, :entity
+
+ def blankslate_config
+ @blankslate_config ||= if params[:filters].blank?
+ unfiltered_blankslate_config
+ else
+ filtered_blankslate_config
+ end
+ end
+
+ def unfiltered_blankslate_config
+ {
+ icon: :people,
+ heading_text: I18n.t("sharing.text_empty_state_header"),
+ description_text: I18n.t("sharing.text_empty_state_description", entity: @entity.class.model_name.human)
+ }
+ end
+
+ def filtered_blankslate_config
+ {
+ icon: :search,
+ heading_text: I18n.t("sharing.text_empty_search_header"),
+ description_text: I18n.t("sharing.text_empty_search_description")
+ }
+ end
+ end
+end
diff --git a/app/components/shares/invite_user_form_component.html.erb b/app/components/shares/invite_user_form_component.html.erb
index 5900c1a2ef47..6cfe10cc5061 100644
--- a/app/components/shares/invite_user_form_component.html.erb
+++ b/app/components/shares/invite_user_form_component.html.erb
@@ -1,17 +1,15 @@
<%=
component_wrapper do
- if @sharing_manageable
+ if strategy.manageable?
primer_form_with(
model: new_share,
- url: url_for([@entity, Member]),
- data: { controller: 'user-limit ' \
- 'shares--user-selected',
+ url: url_for([entity, Member]),
+ data: { controller: 'user-limit shares--user-selected',
'application-target': 'dynamic',
'user-limit-open-seats-value': OpenProject::Enterprise.open_seats_count,
action: 'submit->shares--user-selected#ensureUsersSelected' }
) do |form|
- grid_layout('invite-user-form',
- tag: :div) do |invite_form|
+ grid_layout('invite-user-form', tag: :div) do |invite_form|
invite_form.with_area('invitee') do
render(Shares::Invitee.new(form))
end
@@ -19,7 +17,7 @@
invite_form.with_area('permission') do
render(Shares::PermissionButtonComponent.new(
share: new_share,
- available_roles: @available_roles,
+ available_roles: strategy.available_roles,
form_arguments: { builder: form, name: "role_id" },
data: { 'test-selector': 'op-share-dialog-invite-role' })
)
@@ -46,7 +44,7 @@
I18n.t(
"sharing.warning_user_limit_reached#{'_admin' if User.current.admin?}",
upgrade_url: OpenProject::Enterprise.upgrade_url,
- entity: @entity.model_name.human
+ entity: entity.model_name.human
).html_safe
end
end
@@ -65,7 +63,7 @@
no_selected_user_row.with_column do
render(Primer::Beta::Text.new(color: :danger)) do
- I18n.t("sharing.warning_no_selected_user", entity: @entity.model_name.human)
+ I18n.t("sharing.warning_no_selected_user", entity: entity.model_name.human)
end
end
end
@@ -96,7 +94,7 @@
end
end
else
- render(Primer::Alpha::Banner.new(icon: :info)) { I18n.t('sharing.denied', entities: @entity.model_name.human(count: 2)) }
+ render(Primer::Alpha::Banner.new(icon: :info)) { I18n.t('sharing.denied', entities: entity.model_name.human(count: 2)) }
end
end
%>
diff --git a/app/components/shares/invite_user_form_component.rb b/app/components/shares/invite_user_form_component.rb
index 48ae8e0715ed..92cd7bf922bc 100644
--- a/app/components/shares/invite_user_form_component.rb
+++ b/app/components/shares/invite_user_form_component.rb
@@ -32,26 +32,24 @@ class InviteUserFormComponent < ApplicationComponent # rubocop:disable OpenProje
include OpTurbo::Streamable
include OpPrimer::ComponentHelpers
- def initialize(entity:,
- available_roles:,
- sharing_manageable:,
- errors: nil)
+ attr_reader :entity, :strategy, :errors
+
+ def initialize(strategy:, errors: nil)
super
- @entity = entity
- @available_roles = available_roles
- @sharing_manageable = sharing_manageable
+ @strategy = strategy
+ @entity = strategy.entity
@errors = errors
end
def new_share
- @new_share ||= Member.new(entity: @entity, roles: [Role.new(id: default_role[:value])])
+ @new_share ||= Member.new(entity:, roles: [Role.new(id: default_role[:value])])
end
private
def default_role
- @available_roles.find { |role_hash| role_hash[:default] } || @available_roles.first
+ strategy.available_roles.find { |role_hash| role_hash[:default] } || strategy.available_roles.first
end
end
end
diff --git a/app/components/shares/modal_body_component.html.erb b/app/components/shares/modal_body_component.html.erb
index 97de19aeae13..a4cf6c39cb98 100644
--- a/app/components/shares/modal_body_component.html.erb
+++ b/app/components/shares/modal_body_component.html.erb
@@ -1,24 +1,25 @@
<%=
component_wrapper(tag: 'turbo-frame') do
- flex_layout(data: { turbo: true }) do |modal_content|
+ flex_layout(data: { turbo: true },
+ classes: "op-share-dialog-modal-body--row-container") do |modal_content|
+ if strategy.custom_body_components?
+ strategy.additional_body_components.each do |component|
+ render(component.new(strategy: strategy, modal_body_container: modal_content))
+ end
+ end
+
modal_content.with_row do
- render(Shares::InviteUserFormComponent.new(entity: @entity,
- available_roles: @available_roles,
- sharing_manageable: @sharing_manageable,
- errors: @errors))
+ render(Shares::InviteUserFormComponent.new(strategy:, errors: errors))
end
- modal_content.with_row(mt: 3,
- data: { 'test-selector': 'op-share-dialog-active-list',
+ modal_content.with_row(data: { 'test-selector': 'op-share-dialog-active-list',
controller: 'shares--bulk-selection',
application_target: 'dynamic' }) do
render(border_box_container(list_id: insert_target_modifier_id)) do |border_box|
border_box.with_header(color: :muted, data: { 'test-selector': 'op-share-dialog-header' }) do
grid_layout('op-share-dialog-modal-body--header', tag: :div, align_items: :center) do |header_grid|
header_grid.with_area(:counter, tag: :div) do
- render(Shares::CounterComponent.new(entity: @entity,
- count: @shares.size,
- sharing_manageable: @sharing_manageable))
+ render(Shares::CounterComponent.new(strategy:, count: @shares.size))
end
header_grid.with_area(:actions,
@@ -28,6 +29,7 @@
header_actions.with_column(mr: 2) do
render(Primer::Alpha::ActionMenu.new(anchor_align: :end,
select_variant: :single,
+ size: :small,
dynamic_label: true,
dynamic_label_prefix: I18n.t('sharing.filter.type'),
color: :muted,
@@ -50,6 +52,7 @@
header_actions.with_column do
render(Primer::Alpha::ActionMenu.new(anchor_align: :end,
select_variant: :single,
+ size: :small,
dynamic_label: true,
dynamic_label_prefix: I18n.t('sharing.filter.role'),
color: :muted,
@@ -58,7 +61,7 @@
button.with_trailing_action_icon(icon: "triangle-down")
I18n.t('sharing.filter.role')
end
- @available_roles.each do |role_hash|
+ strategy.available_roles.each do |role_hash|
menu.with_item(label: role_hash[:label],
href: filter_url(role_option: role_hash),
method: :get,
@@ -75,13 +78,11 @@
tag: :div,
hidden: true, # Prevent flicker on initial render
data: { 'shares--bulk-selection-target': 'bulkActions' }) do
- if @sharing_manageable
- concat(
- render(Shares::BulkPermissionButtonComponent.new(entity: @entity, available_roles: @available_roles))
- )
+ if strategy.manageable?
+ concat(render(Shares::BulkPermissionButtonComponent.new(strategy:)))
concat(
- form_with(url: url_for([:bulk, @entity, Member]),
+ form_with(url: url_for([:bulk, entity, Member]),
method: :delete,
data: { 'shares--bulk-selection-target': 'bulkForm' }) do
render(Primer::Beta::IconButton.new(icon: "trash",
@@ -96,26 +97,17 @@
end
end
- if @shares.blank?
+ if @shares.none?
border_box.with_row do
- render(Primer::Beta::Blankslate.new) do |component|
- component.with_visual_icon(icon: blankslate_config[:icon], size: :medium)
- component.with_heading(tag: :h2).with_content(blankslate_config[:heading_text])
- component.with_description do
- flex_layout do |flex|
- flex.with_row(mb: 2) do
- render(Primer::Beta::Text.new(color: :subtle)) { blankslate_config[:description_text] }
- end
- end
- end
+ if strategy.custom_empty_state_component?
+ render(strategy.empty_state_component.new(strategy: strategy))
+ else
+ render(Shares::EmptyStateComponent.new(strategy: strategy))
end
end
else
@shares.each do |share|
- render(Shares::ShareRowComponent.new(share: share,
- available_roles: @available_roles,
- sharing_manageable: @sharing_manageable,
- container: border_box))
+ render(Shares::ShareRowComponent.new(share:, strategy:, container: border_box))
end
end
end
diff --git a/app/components/shares/modal_body_component.rb b/app/components/shares/modal_body_component.rb
index 588f60d32016..231cb4bd070a 100644
--- a/app/components/shares/modal_body_component.rb
+++ b/app/components/shares/modal_body_component.rb
@@ -33,23 +33,17 @@ class ModalBodyComponent < ApplicationComponent # rubocop:disable OpenProject/Ad
include OpTurbo::Streamable
include OpPrimer::ComponentHelpers
- attr_reader :entity,
+ attr_reader :strategy,
+ :entity,
:shares,
- :available_roles,
- :sharing_manageable,
:errors
- def initialize(entity:,
- shares:,
- available_roles:,
- sharing_manageable:,
- errors: nil)
+ def initialize(strategy:, shares:, errors: nil)
super
- @entity = entity
+ @strategy = strategy
+ @entity = strategy.entity
@shares = shares
- @available_roles = available_roles
- @sharing_manageable = sharing_manageable
@errors = errors
end
@@ -71,20 +65,6 @@ def insert_target_modifier_id
"op-share-dialog-active-shares"
end
- def blankslate_config # rubocop:disable Metrics/AbcSize
- @blankslate_config ||= {}.tap do |config|
- if params[:filters].blank?
- config[:icon] = :people
- config[:heading_text] = I18n.t("sharing.text_empty_state_header")
- config[:description_text] = I18n.t("sharing.text_empty_state_description", entity: @entity.class.model_name.human)
- else
- config[:icon] = :search
- config[:heading_text] = I18n.t("sharing.text_empty_search_header")
- config[:description_text] = I18n.t("sharing.text_empty_search_description")
- end
- end
- end
-
def type_filter_options
if project_scoped_entity?
[
@@ -106,15 +86,16 @@ def type_filter_options
end
end
- def type_filter_option_active?(option)
+ def type_filter_option_active?(option) # rubocop:disable Metrics/AbcSize
principal_type_filter_value = current_filter_value(params[:filters], "principal_type")
project_member_filter_value = current_filter_value(params[:filters], "also_project_member")
- return false if principal_type_filter_value.nil? || project_member_filter_value.nil?
+ return false if principal_type_filter_value.nil?
+ return false if project_scoped_entity? && project_member_filter_value.nil?
principal_type_checked =
option[:value][:principal_type] == principal_type_filter_value
- membership_selected =
+ membership_selected = !project_scoped_entity? ||
option[:value][:project_member] == ActiveRecord::Type::Boolean.new.cast(project_member_filter_value)
principal_type_checked && membership_selected
@@ -125,7 +106,7 @@ def role_filter_option_active?(option)
return false if role_filter_value.nil?
- selected_role = @available_roles.find { _1[:value] == option[:value] }
+ selected_role = strategy.available_roles.find { _1[:value] == option[:value] }
selected_role[:value] == role_filter_value.to_i
end
@@ -183,14 +164,18 @@ def apply_type_filter(option)
end
def type_filter_for(option)
- filter = []
- if ActiveRecord::Type::Boolean.new.cast(option[:value][:project_member])
- filter.push({ also_project_member: { operator: "=", values: [OpenProject::Database::DB_VALUE_TRUE] } })
- else
- filter.push({ also_project_member: { operator: "=", values: [OpenProject::Database::DB_VALUE_FALSE] } })
+ filter = [
+ { principal_type: { operator: "=", values: [option[:value][:principal_type]] } }
+ ]
+
+ if project_scoped_entity?
+ if ActiveRecord::Type::Boolean.new.cast(option[:value][:project_member])
+ filter.push({ also_project_member: { operator: "=", values: [OpenProject::Database::DB_VALUE_TRUE] } })
+ else
+ filter.push({ also_project_member: { operator: "=", values: [OpenProject::Database::DB_VALUE_FALSE] } })
+ end
end
- filter.push({ principal_type: { operator: "=", values: [option[:value][:principal_type]] } })
filter
end
diff --git a/app/components/shares/modal_body_component.sass b/app/components/shares/modal_body_component.sass
index 17f99bc4aab1..44f6e8260e20 100644
--- a/app/components/shares/modal_body_component.sass
+++ b/app/components/shares/modal_body_component.sass
@@ -1,4 +1,7 @@
.op-share-dialog-modal-body
+ &--row-container
+ gap: 16px
+
&--user-row
display: grid
grid-template-columns: minmax(31px, auto) 1fr // 31px is the width needed to display a group avatar
diff --git a/app/components/shares/permission_button_component.html.erb b/app/components/shares/permission_button_component.html.erb
index 6402891c82ad..24a63a92b6d9 100644
--- a/app/components/shares/permission_button_component.html.erb
+++ b/app/components/shares/permission_button_component.html.erb
@@ -1,6 +1,7 @@
<%=
component_wrapper do
render(Primer::Alpha::ActionMenu.new(**{ select_variant: :single,
+ size: :small,
dynamic_label: true,
anchor_align: :end,
color: :subtle }.deep_merge(@system_arguments))) do |menu|
diff --git a/app/components/shares/project_queries/empty_state_component.html.erb b/app/components/shares/project_queries/empty_state_component.html.erb
new file mode 100644
index 000000000000..64e117e461b4
--- /dev/null
+++ b/app/components/shares/project_queries/empty_state_component.html.erb
@@ -0,0 +1,15 @@
+<%= render(Primer::Beta::Blankslate.new) do |component|
+ component.with_visual_icon(icon: blankslate_config[:icon], size: :medium)
+ component.with_heading(tag: :h2).with_content(
+ blankslate_config[:heading_text],
+ )
+ component.with_description do
+ flex_layout do |flex|
+ flex.with_row(mb: 2) do
+ render(Primer::Beta::Text.new(color: :subtle)) do
+ blankslate_config[:description_text]
+ end
+ end
+ end
+ end
+end %>
diff --git a/app/components/shares/project_queries/empty_state_component.rb b/app/components/shares/project_queries/empty_state_component.rb
new file mode 100644
index 000000000000..b02fcb9d8dd6
--- /dev/null
+++ b/app/components/shares/project_queries/empty_state_component.rb
@@ -0,0 +1,80 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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 Shares
+ module ProjectQueries
+ class EmptyStateComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent
+ include OpPrimer::ComponentHelpers
+
+ def initialize(strategy:)
+ super
+
+ @strategy = strategy
+ @entity = strategy.entity
+ end
+
+ private
+
+ attr_reader :strategy, :entity
+
+ def blankslate_config
+ @blankslate_config ||= if entity.public?
+ public_blankslate_config
+ elsif params[:filters].blank?
+ unfiltered_blankslate_config
+ else
+ filtered_blankslate_config
+ end
+ end
+
+ def public_blankslate_config
+ {
+ icon: :people,
+ heading_text: I18n.t("sharing.project_queries.blank_state.public.header"),
+ description_text: I18n.t("sharing.project_queries.blank_state.public.description")
+ }
+ end
+
+ def unfiltered_blankslate_config
+ {
+ icon: :people,
+ heading_text: I18n.t("sharing.project_queries.blank_state.private.header"),
+ description_text: I18n.t("sharing.project_queries.blank_state.private.description")
+ }
+ end
+
+ def filtered_blankslate_config
+ {
+ icon: :search,
+ heading_text: I18n.t("sharing.text_empty_search_header"),
+ description_text: I18n.t("sharing.text_empty_search_description")
+ }
+ end
+ end
+ end
+end
diff --git a/app/components/shares/project_queries/project_access_warning_component.html.erb b/app/components/shares/project_queries/project_access_warning_component.html.erb
new file mode 100644
index 000000000000..629a8b7b9dca
--- /dev/null
+++ b/app/components/shares/project_queries/project_access_warning_component.html.erb
@@ -0,0 +1,3 @@
+<%- if query_is_public? || query_is_shared? %>
+ <%= container.with_row { render(Primer::Alpha::Banner.new(icon: :info)) { I18n.t('sharing.project_queries.access_warning') } }%>
+<%- end %>
diff --git a/app/components/shares/project_queries/project_access_warning_component.rb b/app/components/shares/project_queries/project_access_warning_component.rb
new file mode 100644
index 000000000000..97b76a75d61f
--- /dev/null
+++ b/app/components/shares/project_queries/project_access_warning_component.rb
@@ -0,0 +1,26 @@
+module Shares
+ module ProjectQueries
+ class ProjectAccessWarningComponent < ViewComponent::Base # rubocop:disable OpenProject/AddPreviewForViewComponent
+ include OpPrimer::ComponentHelpers
+
+ def initialize(strategy:, modal_body_container:)
+ super
+
+ @strategy = strategy
+ @container = modal_body_container
+ end
+
+ private
+
+ attr_reader :strategy, :container
+
+ def query_is_public?
+ @strategy.entity.public?
+ end
+
+ def query_is_shared?
+ @strategy.entity.members.any?
+ end
+ end
+ end
+end
diff --git a/app/components/shares/project_queries/public_flag_component.html.erb b/app/components/shares/project_queries/public_flag_component.html.erb
new file mode 100644
index 000000000000..aec171db24e8
--- /dev/null
+++ b/app/components/shares/project_queries/public_flag_component.html.erb
@@ -0,0 +1,14 @@
+<%=
+container.with_row do
+ render(Primer::Forms::ToggleSwitchForm.new(
+ name: "publish_project_query",
+ label: t('sharing.project_queries.public_flag.label', instance_name: Setting.app_title),
+ caption: t('sharing.project_queries.public_flag.caption'),
+ src: toggle_public_flag_link,
+ csrf_token: form_authenticity_token,
+ status_label_position: :start,
+ checked: published?,
+ enabled: can_publish?
+ ))
+end
+%>
diff --git a/app/components/shares/project_queries/public_flag_component.rb b/app/components/shares/project_queries/public_flag_component.rb
new file mode 100644
index 000000000000..813c5caa2a11
--- /dev/null
+++ b/app/components/shares/project_queries/public_flag_component.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+# -- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2023 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 Shares
+ module ProjectQueries
+ class PublicFlagComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent
+ include OpTurbo::Streamable
+ include OpPrimer::ComponentHelpers
+
+ def initialize(strategy:, modal_body_container:)
+ super
+
+ @strategy = strategy
+ @container = modal_body_container
+ end
+
+ private
+
+ attr_reader :strategy, :container
+
+ def toggle_public_flag_link
+ toggle_public_project_query_path(strategy.entity)
+ end
+
+ def published?
+ strategy.entity.public?
+ end
+
+ def can_publish?
+ User.current.allowed_globally?(:manage_public_project_queries)
+ end
+ end
+ end
+end
diff --git a/app/components/shares/share_dialog_component.html.erb b/app/components/shares/share_dialog_component.html.erb
new file mode 100644
index 000000000000..df84ba60efd5
--- /dev/null
+++ b/app/components/shares/share_dialog_component.html.erb
@@ -0,0 +1,8 @@
+<%=
+ render(Primer::Alpha::Dialog.new(title: t(:label_share_project_list), id: 'sharing-modal', data: { 'keep-open-on-submit': true }, size: :xlarge)) do |d|
+ d.with_header(variant: :large, mb: 3)
+ d.with_body do
+ render(Shares::ModalBodyComponent.new(strategy:, shares:, errors:))
+ end
+ end
+%>
diff --git a/app/components/shares/share_dialog_component.rb b/app/components/shares/share_dialog_component.rb
new file mode 100644
index 000000000000..702f5adc5567
--- /dev/null
+++ b/app/components/shares/share_dialog_component.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+# -- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2023 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 Shares
+ class ShareDialogComponent < ApplicationComponent
+ include ApplicationHelper
+ include OpTurbo::Streamable
+ include OpPrimer::ComponentHelpers
+
+ def initialize(shares:, strategy:, errors:)
+ super
+
+ @shares = shares
+ @strategy = strategy
+ @errors = errors
+ end
+
+ private
+
+ attr_reader :shares, :strategy, :errors
+ end
+end
diff --git a/app/components/shares/share_row_component.rb b/app/components/shares/share_row_component.rb
index 8f9a1dd6e53f..1a4376af63de 100644
--- a/app/components/shares/share_row_component.rb
+++ b/app/components/shares/share_row_component.rb
@@ -34,17 +34,14 @@ class ShareRowComponent < ApplicationComponent # rubocop:disable OpenProject/Add
include OpTurbo::Streamable
include OpPrimer::ComponentHelpers
- def initialize(share:,
- available_roles:,
- sharing_manageable:,
- container: nil)
+ def initialize(share:, strategy:, container: nil)
super
@share = share
- @entity = share.entity
+ @strategy = strategy
+ @entity = strategy.entity
@principal = share.principal
- @available_roles = available_roles
- @sharing_manageable = sharing_manageable
+ @available_roles = strategy.available_roles
@container = container
end
@@ -54,13 +51,15 @@ def wrapper_uniq_by
private
- attr_reader :share, :entity, :principal, :container, :available_roles
+ attr_reader :share, :entity, :principal, :container, :available_roles, :strategy
def share_editable?
@share_editable ||= User.current != share.principal && sharing_manageable?
end
- def sharing_manageable? = @sharing_manageable
+ def sharing_manageable?
+ strategy.manageable?
+ end
def grid_css_classes
if sharing_manageable?
diff --git a/app/controllers/concerns/shares/work_packages/authorization.rb b/app/contracts/shares/project_queries/base_extension.rb
similarity index 72%
rename from app/controllers/concerns/shares/work_packages/authorization.rb
rename to app/contracts/shares/project_queries/base_extension.rb
index 32b4bf5f34e0..7392139fe6f3 100644
--- a/app/controllers/concerns/shares/work_packages/authorization.rb
+++ b/app/contracts/shares/project_queries/base_extension.rb
@@ -29,23 +29,18 @@
# ++
module Shares
- module WorkPackages
- module Authorization
+ module ProjectQueries
+ module BaseExtension
extend ActiveSupport::Concern
- included do
- def sharing_manageable?
- # TODO: Fix this to check based on the entity
- case @entity
- when WorkPackage
- User.current.allowed_in_project?(:share_work_packages, @entity.project)
- else
- raise ArgumentError, <<~ERROR
- Checking sharing capabilities for an unsupported entity:
- - #{@entity.class}
- ERROR
- end
- end
+ private
+
+ def user_allowed_to_manage?
+ model.entity.editable?
+ end
+
+ def assignable_role_class
+ ProjectQueryRole
end
end
end
diff --git a/app/contracts/shares/project_queries/create_contract.rb b/app/contracts/shares/project_queries/create_contract.rb
new file mode 100644
index 000000000000..d9ce1351d68d
--- /dev/null
+++ b/app/contracts/shares/project_queries/create_contract.rb
@@ -0,0 +1,35 @@
+# -- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2010-2024 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 Shares
+ module ProjectQueries
+ class CreateContract < Shares::CreateContract
+ include Shares::ProjectQueries::BaseExtension
+ end
+ end
+end
diff --git a/app/contracts/shares/project_queries/delete_contract.rb b/app/contracts/shares/project_queries/delete_contract.rb
new file mode 100644
index 000000000000..925dc2d18e76
--- /dev/null
+++ b/app/contracts/shares/project_queries/delete_contract.rb
@@ -0,0 +1,37 @@
+# -- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2010-2024 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 Shares
+ module ProjectQueries
+ class DeleteContract < Shares::DeleteContract
+ # DeleteContract has its own permission check and does not care about the role class,
+ # so we do not need to include the BaseExtension here.
+ delete_permission -> { model.entity.editable? }
+ end
+ end
+end
diff --git a/app/contracts/shares/project_queries/update_contract.rb b/app/contracts/shares/project_queries/update_contract.rb
new file mode 100644
index 000000000000..eac81bc4e190
--- /dev/null
+++ b/app/contracts/shares/project_queries/update_contract.rb
@@ -0,0 +1,35 @@
+# -- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2010-2024 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 Shares
+ module ProjectQueries
+ class UpdateContract < Shares::UpdateContract
+ include Shares::ProjectQueries::BaseExtension
+ end
+ end
+end
diff --git a/app/controllers/members_controller.rb b/app/controllers/members_controller.rb
index 0a0a3179c56f..7fbba42d4f5a 100644
--- a/app/controllers/members_controller.rb
+++ b/app/controllers/members_controller.rb
@@ -144,8 +144,8 @@ def members_table_options(roles)
available_roles: roles,
authorize_update: authorize_for("members", :update),
authorize_delete: authorize_for("members", :destroy),
- authorize_work_package_shares_view: authorize_for("shares", :update),
- authorize_work_package_shares_delete: authorize_for("shares", :bulk_destroy),
+ authorize_work_package_shares_view: current_user.allowed_in_project?(:view_shared_work_packages, @project),
+ authorize_work_package_shares_delete: current_user.allowed_in_project?(:share_work_packages, @project),
authorize_manage_user: current_user.allowed_globally?(:manage_user),
is_filtered: Members::UserFilterComponent.filtered?(params),
shared_role_name:
diff --git a/app/controllers/projects/queries_controller.rb b/app/controllers/projects/queries_controller.rb
index abbc67b2c4fc..e1111712033b 100644
--- a/app/controllers/projects/queries_controller.rb
+++ b/app/controllers/projects/queries_controller.rb
@@ -30,9 +30,9 @@ class Projects::QueriesController < ApplicationController
include Projects::QueryLoading
# No need for a more specific authorization check. That is carried out in the contracts.
- no_authorization_required! :show, :new, :create, :rename, :update, :publish, :unpublish, :destroy
+ no_authorization_required! :show, :new, :create, :rename, :update, :toggle_public, :destroy
before_action :require_login
- before_action :find_query, only: %i[show rename update destroy publish unpublish]
+ before_action :find_query, only: %i[show rename update destroy toggle_public]
before_action :build_query_or_deny_access, only: %i[new create]
current_menu_item [:new, :rename, :create, :update] do
@@ -71,20 +71,15 @@ def update
render_result(call, success_i18n_key: "lists.update.success", error_i18n_key: "lists.update.failure")
end
- def publish
- call = Queries::Projects::ProjectQueries::PublishService
- .new(user: current_user, model: @query)
- .call(public: true)
-
- render_result(call, success_i18n_key: "lists.publish.success", error_i18n_key: "lists.publish.failure")
- end
+ def toggle_public
+ to_be_public = !@query.public?
+ i18n_key = to_be_public ? "lists.publish" : "lists.unpublish"
- def unpublish
call = Queries::Projects::ProjectQueries::PublishService
.new(user: current_user, model: @query)
- .call(public: false)
+ .call(public: to_be_public)
- render_result(call, success_i18n_key: "lists.unpublish.success", error_i18n_key: "lists.unpublish.failure")
+ render_result(call, success_i18n_key: "#{i18n_key}.success", error_i18n_key: "#{i18n_key}.failure")
end
def destroy
diff --git a/app/controllers/shares_controller.rb b/app/controllers/shares_controller.rb
index 2de1aef7aeda..5eebc6cef77e 100644
--- a/app/controllers/shares_controller.rb
+++ b/app/controllers/shares_controller.rb
@@ -28,26 +28,27 @@
class SharesController < ApplicationController
include OpTurbo::ComponentStream
- include Shares::WorkPackages::Authorization
+ include OpTurbo::DialogStreamHelper
include MemberHelper
before_action :load_entity
- before_action :load_shares, only: %i[index]
+ before_action :load_shares, only: %i[index dialog]
before_action :load_selected_shares, only: %i[bulk_update bulk_destroy]
before_action :load_share, only: %i[destroy update resend_invite]
- before_action :authorize
before_action :enterprise_check, only: %i[index]
+ before_action :check_if_manageable, except: %i[index dialog]
+ before_action :check_if_viewable, only: %i[index dialog]
+ authorization_checked! :dialog, :index, :create, :update, :destroy, :resend_invite, :bulk_update, :bulk_destroy
+
+ def dialog; end
+
def index
unless @query.valid?
flash.now[:error] = query.errors.full_messages
end
- render Shares::ModalBodyComponent.new(entity: @entity,
- shares: @shares,
- errors: @errors,
- sharing_manageable: sharing_manageable?,
- available_roles:), layout: nil
+ render Shares::ModalBodyComponent.new(strategy: sharing_strategy, shares: @shares, errors: @errors), layout: nil
end
def create # rubocop:disable Metrics/AbcSize,Metrics/PerceivedComplexity
@@ -129,6 +130,20 @@ def bulk_destroy
private
+ attr_reader :sharing_strategy
+
+ def check_if_viewable
+ return if sharing_strategy.viewable? || sharing_strategy.manageable?
+
+ render_403
+ end
+
+ def check_if_manageable
+ return if sharing_strategy.manageable?
+
+ render_403
+ end
+
def enterprise_check
return if EnterpriseToken.allows_to?(:work_package_sharing)
@@ -137,26 +152,23 @@ def enterprise_check
def destroy_share(share)
Shares::DeleteService
- .new(user: current_user, model: share, contract_class: sharing_contract_scope::DeleteContract)
+ .new(user: current_user, model: share, contract_class: sharing_strategy.delete_contract_class)
.call
end
def create_or_update_share(user_id, role_ids)
Shares::CreateOrUpdateService.new(
user: current_user,
- create_contract_class: sharing_contract_scope::CreateContract,
- update_contract_class: sharing_contract_scope::UpdateContract
- )
- .call(entity: @entity, user_id:, role_ids:)
+ create_contract_class: sharing_strategy.create_contract_class,
+ update_contract_class: sharing_strategy.update_contract_class
+ ).call(entity: @entity, user_id:, role_ids:)
end
def respond_with_replace_modal
replace_via_turbo_stream(
component: Shares::ModalBodyComponent.new(
- entity: @entity,
- available_roles:,
+ strategy: sharing_strategy,
shares: @new_shares || load_shares,
- sharing_manageable: sharing_manageable?,
errors: @errors
)
)
@@ -164,21 +176,18 @@ def respond_with_replace_modal
respond_with_turbo_streams
end
- def respond_with_prepend_shares # rubocop:disable Metrics/AbcSize
+ def respond_with_prepend_shares
replace_via_turbo_stream(
component: Shares::InviteUserFormComponent.new(
- entity: @entity,
- available_roles:,
- sharing_manageable: sharing_manageable?,
+ strategy: sharing_strategy,
errors: @errors
)
)
update_via_turbo_stream(
component: Shares::CounterComponent.new(
- entity: @entity,
- count: current_visible_member_count,
- sharing_manageable: sharing_manageable?
+ strategy: sharing_strategy,
+ count: current_visible_member_count
)
)
@@ -186,13 +195,10 @@ def respond_with_prepend_shares # rubocop:disable Metrics/AbcSize
prepend_via_turbo_stream(
component: Shares::ShareRowComponent.new(
share:,
- available_roles:,
- sharing_manageable: sharing_manageable?
+ strategy: sharing_strategy
),
target_component: Shares::ModalBodyComponent.new(
- entity: @entity,
- available_roles:,
- sharing_manageable: sharing_manageable?,
+ strategy: sharing_strategy,
shares: load_shares,
errors: @errors
)
@@ -205,9 +211,7 @@ def respond_with_prepend_shares # rubocop:disable Metrics/AbcSize
def respond_with_new_invite_form
replace_via_turbo_stream(
component: Shares::InviteUserFormComponent.new(
- entity: @entity,
- available_roles:,
- sharing_manageable: sharing_manageable?,
+ strategy: sharing_strategy,
errors: @errors
)
)
@@ -219,7 +223,7 @@ def respond_with_update_permission_button
replace_via_turbo_stream(
component: Shares::PermissionButtonComponent.new(
share: @share,
- available_roles:,
+ available_roles: sharing_strategy.available_roles,
data: { "test-selector": "op-share-dialog-update-role" }
)
)
@@ -231,15 +235,13 @@ def respond_with_remove_share
remove_via_turbo_stream(
component: Shares::ShareRowComponent.new(
share: @share,
- available_roles:,
- sharing_manageable: sharing_manageable?
+ strategy: sharing_strategy
)
)
update_via_turbo_stream(
component: Shares::CounterComponent.new(
- entity: @entity,
- count: current_visible_member_count,
- sharing_manageable: sharing_manageable?
+ strategy: sharing_strategy,
+ count: current_visible_member_count
)
)
@@ -250,7 +252,7 @@ def respond_with_update_user_details
update_via_turbo_stream(
component: Shares::UserDetailsComponent.new(
share: @share,
- manager_mode: sharing_manageable?,
+ manager_mode: sharing_strategy.manageable?,
invite_resent: true
)
)
@@ -263,7 +265,7 @@ def respond_with_bulk_updated_permission_buttons
replace_via_turbo_stream(
component: Shares::PermissionButtonComponent.new(
share:,
- available_roles:,
+ available_roles: sharing_strategy.available_roles,
data: { "test-selector": "op-share-dialog-update-role" }
)
)
@@ -277,33 +279,38 @@ def respond_with_bulk_removed_shares
remove_via_turbo_stream(
component: Shares::ShareRowComponent.new(
share:,
- available_roles:,
- sharing_manageable: sharing_manageable?
+ strategy: sharing_strategy
)
)
end
update_via_turbo_stream(
component: Shares::CounterComponent.new(
- entity: @entity,
count: current_visible_member_count,
- sharing_manageable: sharing_manageable?
+ strategy: sharing_strategy
)
)
respond_with_turbo_streams
end
- def load_entity
- @entity = if params["work_package_id"]
- WorkPackage.visible.find(params["work_package_id"])
- # TODO: Add support for other entities
- else
- raise ArgumentError, <<~ERROR
- Nested the SharesController under an entity controller that is not yet configured to support sharing.
- Edit the SharesController#load_entity method to load the entity from the correct parent.
- ERROR
- end
+ def load_entity # rubocop:disable Metrics/AbcSize
+ if params["work_package_id"]
+ @entity = WorkPackage.visible.find(params["work_package_id"])
+ @sharing_strategy = SharingStrategies::WorkPackageStrategy.new(@entity, user: current_user)
+ elsif params["project_query_id"]
+ @entity = ProjectQuery.visible.find(params["project_query_id"])
+ @sharing_strategy = SharingStrategies::ProjectQueryStrategy.new(@entity, user: current_user)
+ else
+ raise ArgumentError, <<~ERROR
+ Nested the SharesController under an entity controller that is not yet configured to support sharing.
+ Edit the SharesController#load_entity method to load the entity from the correct parent and specify what sharing
+ strategy should be applied.
+
+ Params: #{params.to_unsafe_h}
+ Request Path: #{request.path}
+ ERROR
+ end
if @entity.respond_to?(:project)
@project = @entity.project
@@ -322,8 +329,8 @@ def load_query
return @query if defined?(@query)
@query = ParamsToQueryService
- .new(Member, current_user, query_class: Queries::Members::EntityMemberQuery)
- .call(params)
+ .new(Member, current_user, query_class: Queries::Members::NonInheritedMemberQuery)
+ .call(params)
# Set default filter on the entity
@query.where("entity_id", "=", @entity.id)
@@ -346,31 +353,4 @@ def load_selected_shares
.of_entity(@entity)
.where(id: params[:share_ids])
end
-
- def available_roles
- @available_roles ||= if @entity.is_a?(WorkPackage)
- role_mapping = WorkPackageRole.unscoped.pluck(:builtin, :id).to_h
-
- [
- { label: I18n.t("work_package.permissions.edit"),
- value: role_mapping[Role::BUILTIN_WORK_PACKAGE_EDITOR],
- description: I18n.t("work_package.permissions.edit_description") },
- { label: I18n.t("work_package.permissions.comment"),
- value: role_mapping[Role::BUILTIN_WORK_PACKAGE_COMMENTER],
- description: I18n.t("work_package.permissions.comment_description") },
- { label: I18n.t("work_package.permissions.view"),
- value: role_mapping[Role::BUILTIN_WORK_PACKAGE_VIEWER],
- description: I18n.t("work_package.permissions.view_description"),
- default: true }
- ]
- else
- []
- end
- end
-
- def sharing_contract_scope
- if @entity.is_a?(WorkPackage)
- Shares::WorkPackages
- end
- end
end
diff --git a/app/forms/shares/invitee.rb b/app/forms/shares/invitee.rb
index e6857c141cd0..61d5a3d41aba 100644
--- a/app/forms/shares/invitee.rb
+++ b/app/forms/shares/invitee.rb
@@ -50,7 +50,7 @@ class Invitee < ApplicationForm
addTagText: I18n.t("members.send_invite_to"),
multiple: true,
focusDirectly: true,
- appendTo: "body",
+ appendToComponent: true,
disabled: @disabled
}
)
diff --git a/app/menus/projects/menu.rb b/app/menus/projects/menu.rb
index e7cc468af97c..affc4ecbc9f3 100644
--- a/app/menus/projects/menu.rb
+++ b/app/menus/projects/menu.rb
@@ -108,14 +108,15 @@ def public_filters
def my_filters
persisted_filters
+ .reject(&:public?)
.select { |query| query.user == current_user }
.map { |query| menu_item(query.name, query_id: query.id) }
end
def shared_filters
persisted_filters
- .select { |query| !query.public? && query.user != current_user }
- # query is not public and not owned by the user, so it must be shared with them
+ .reject(&:public?)
+ .reject { |query| query.user == current_user }
.map { |query| menu_item(query.name, query_id: query.id) }
end
diff --git a/app/models/members/scopes/of_entity.rb b/app/models/members/scopes/of_entity.rb
index b0082ce97547..4ebf97108f6b 100644
--- a/app/models/members/scopes/of_entity.rb
+++ b/app/models/members/scopes/of_entity.rb
@@ -33,8 +33,11 @@ module OfEntity
class_methods do
# Find all members of a specific Work Package
def of_entity(entity)
- of_any_entity
- .where(entity:)
+ if entity.respond_to?(:project)
+ where(project: entity.project, entity:)
+ else
+ where(project: nil, entity:)
+ end
end
end
end
diff --git a/app/models/queries/members.rb b/app/models/queries/members.rb
index 98228d61265e..e1b56bfe2c6e 100644
--- a/app/models/queries/members.rb
+++ b/app/models/queries/members.rb
@@ -50,7 +50,7 @@ module Queries::Members
order Orders::StatusOrder
end
- ::Queries::Register.register(EntityMemberQuery) do
+ ::Queries::Register.register(NonInheritedMemberQuery) do
filter Filters::NameFilter
filter Filters::AnyNameAttributeFilter
filter Filters::ProjectFilter
diff --git a/app/models/queries/members/filters/entity_type_filter.rb b/app/models/queries/members/filters/entity_type_filter.rb
index 5ddab94d13fa..60f3be86a865 100644
--- a/app/models/queries/members/filters/entity_type_filter.rb
+++ b/app/models/queries/members/filters/entity_type_filter.rb
@@ -32,7 +32,10 @@ def type
end
def allowed_values
- [[WorkPackage.name, WorkPackage.name]]
+ [
+ [WorkPackage.name, WorkPackage.name],
+ [ProjectQuery.name, ProjectQuery.name]
+ ]
end
def self.key
diff --git a/app/models/queries/members/entity_member_query.rb b/app/models/queries/members/non_inherited_member_query.rb
similarity index 94%
rename from app/models/queries/members/entity_member_query.rb
rename to app/models/queries/members/non_inherited_member_query.rb
index 27714076f0e1..87349f0d1f85 100644
--- a/app/models/queries/members/entity_member_query.rb
+++ b/app/models/queries/members/non_inherited_member_query.rb
@@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
# ++
-class Queries::Members::EntityMemberQuery < Queries::Members::MemberQuery
+class Queries::Members::NonInheritedMemberQuery < Queries::Members::MemberQuery
def default_scope
Member.joins(:member_roles).merge(MemberRole.only_non_inherited)
end
diff --git a/app/models/sharing_strategies/base_strategy.rb b/app/models/sharing_strategies/base_strategy.rb
new file mode 100644
index 000000000000..e28dbf2e55a8
--- /dev/null
+++ b/app/models/sharing_strategies/base_strategy.rb
@@ -0,0 +1,83 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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 SharingStrategies
+ class BaseStrategy
+ attr_reader :entity, :user
+
+ def initialize(entity, user: User.current)
+ @entity = entity
+ @user = user
+ end
+
+ def available_roles
+ # format: [{ label: "Role name", value: 42, description: "Role description", default: true }]
+ raise NotImplementedError, "Override in a subclass and return an array of roles that should be displayed"
+ end
+
+ def viewable?
+ raise NotImplementedError,
+ "Override in a subclass and return true if the current user can view who the entity is shared with"
+ end
+
+ def manageable?
+ raise NotImplementedError, "Override in a subclass and return true if the current user can manage sharing"
+ end
+
+ def create_contract_class
+ raise NotImplementedError, "Override in a subclass and return the contract class for creating a share"
+ end
+
+ def update_contract_class
+ raise NotImplementedError, "Override in a subclass and return the contract class for updating a share"
+ end
+
+ def delete_contract_class
+ raise NotImplementedError, "Override in a subclass and return the contract class for deleting a share"
+ end
+
+ def custom_body_components?
+ !additional_body_components.empty?
+ end
+
+ # Override by returning a list of component classes that should be rendered in the sharing dialog above the table of shares
+ def additional_body_components
+ []
+ end
+
+ def custom_empty_state_component?
+ empty_state_component.present?
+ end
+
+ # Override by returning a component class that should be rendered in the sharing dialog instead of the table of shares
+ # when there is no share yet
+ def empty_state_component
+ nil
+ end
+ end
+end
diff --git a/app/models/sharing_strategies/project_query_strategy.rb b/app/models/sharing_strategies/project_query_strategy.rb
new file mode 100644
index 000000000000..574a41ec3926
--- /dev/null
+++ b/app/models/sharing_strategies/project_query_strategy.rb
@@ -0,0 +1,80 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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 SharingStrategies
+ class ProjectQueryStrategy < BaseStrategy
+ def available_roles
+ role_mapping = ProjectQueryRole.pluck(:builtin, :id).to_h
+
+ [
+ {
+ label: I18n.t("sharing.project_queries.permissions.edit"),
+ value: role_mapping[Role::BUILTIN_PROJECT_QUERY_EDIT],
+ description: I18n.t("sharing.project_queries.permissions.edit_description")
+ },
+ {
+ label: I18n.t("sharing.project_queries.permissions.view"),
+ value: role_mapping[Role::BUILTIN_PROJECT_QUERY_VIEW],
+ description: I18n.t("sharing.project_queries.permissions.view_description"),
+ default: true
+ }
+ ]
+ end
+
+ def manageable?
+ @entity.editable?
+ end
+
+ def viewable?
+ @entity.visible?
+ end
+
+ def create_contract_class
+ Shares::ProjectQueries::CreateContract
+ end
+
+ def update_contract_class
+ Shares::ProjectQueries::UpdateContract
+ end
+
+ def delete_contract_class
+ Shares::ProjectQueries::DeleteContract
+ end
+
+ def additional_body_components
+ [
+ Shares::ProjectQueries::PublicFlagComponent,
+ Shares::ProjectQueries::ProjectAccessWarningComponent
+ ]
+ end
+
+ def empty_state_component
+ Shares::ProjectQueries::EmptyStateComponent
+ end
+ end
+end
diff --git a/app/models/sharing_strategies/work_package_strategy.rb b/app/models/sharing_strategies/work_package_strategy.rb
new file mode 100644
index 000000000000..3d24ef89a816
--- /dev/null
+++ b/app/models/sharing_strategies/work_package_strategy.rb
@@ -0,0 +1,68 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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 SharingStrategies
+ class WorkPackageStrategy < BaseStrategy
+ def available_roles
+ role_mapping = WorkPackageRole.unscoped.pluck(:builtin, :id).to_h
+
+ [
+ { label: I18n.t("work_package.permissions.edit"),
+ value: role_mapping[Role::BUILTIN_WORK_PACKAGE_EDITOR],
+ description: I18n.t("work_package.permissions.edit_description") },
+ { label: I18n.t("work_package.permissions.comment"),
+ value: role_mapping[Role::BUILTIN_WORK_PACKAGE_COMMENTER],
+ description: I18n.t("work_package.permissions.comment_description") },
+ { label: I18n.t("work_package.permissions.view"),
+ value: role_mapping[Role::BUILTIN_WORK_PACKAGE_VIEWER],
+ description: I18n.t("work_package.permissions.view_description"),
+ default: true }
+ ]
+ end
+
+ def manageable?
+ user.allowed_in_project?(:share_work_packages, @entity.project)
+ end
+
+ def viewable?
+ user.allowed_in_project?(:view_shared_work_packages, @entity.project)
+ end
+
+ def create_contract_class
+ Shares::WorkPackages::CreateContract
+ end
+
+ def update_contract_class
+ Shares::WorkPackages::UpdateContract
+ end
+
+ def delete_contract_class
+ Shares::WorkPackages::DeleteContract
+ end
+ end
+end
diff --git a/app/services/authorization/user_permissible_service.rb b/app/services/authorization/user_permissible_service.rb
index a3c8e2629b14..ec7a2bc53015 100644
--- a/app/services/authorization/user_permissible_service.rb
+++ b/app/services/authorization/user_permissible_service.rb
@@ -112,7 +112,9 @@ def allowed_in_single_standalone_entity?(permissions, entity)
return false if entity.nil?
return true if admin_and_all_granted_to_admin?(permissions)
- cached_permissions(entity).intersect?(permissions)
+ permission_names = permissions.map { |perm| perm.name.to_sym }
+
+ cached_permissions(entity).intersect?(permission_names)
end
def allowed_in_any_project_scoped_entity?(permissions, entity_class, in_project:)
diff --git a/app/views/shares/dialog.turbo_stream.erb b/app/views/shares/dialog.turbo_stream.erb
new file mode 100644
index 000000000000..56f9e3b1a3ab
--- /dev/null
+++ b/app/views/shares/dialog.turbo_stream.erb
@@ -0,0 +1,3 @@
+<%= turbo_stream.dialog do
+ render(Shares::ShareDialogComponent.new(strategy: @sharing_strategy, shares: @shares, errors: @errors))
+end %>
diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb
index 8b4ab0de9719..896a1c495add 100644
--- a/config/initializers/permissions.rb
+++ b/config/initializers/permissions.rb
@@ -180,7 +180,7 @@
map.permission :manage_public_project_queries,
{
- "projects/queries": %i[publish unpublish]
+ "projects/queries": %i[toggle_public]
},
permissible_on: :global,
require: :loggedin,
@@ -330,17 +330,14 @@
map.permission :share_work_packages,
{
- members: %i[destroy_by_principal],
- shares: %i[index create destroy update resend_invite bulk_update bulk_destroy]
+ members: %i[destroy_by_principal]
},
permissible_on: :project,
dependencies: %i[edit_work_packages view_shared_work_packages],
require: :member
map.permission :view_shared_work_packages,
- {
- shares: %i[index]
- },
+ {},
permissible_on: :project,
require: :member,
contract_actions: { work_package_shares: %i[index] }
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 455e04034907..1d4bf25f26ba 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -322,8 +322,9 @@ en:
project_custom_fields:
header:
title: "Project attributes"
- description: "These project attributes will be displayed in your project overview page under their respective sections. You can enable or disable individual attributes.
-Project attributes and sections are defined in the administration settings by the administrator of the instance. "
+ description:
+ 'These project attributes will be displayed in your project overview page under their respective sections. You can enable or disable individual attributes.
+ Project attributes and sections are defined in the administration settings by the administrator of the instance. '
filter:
label: "Search project attribute"
actions:
@@ -2158,6 +2159,7 @@ Project attributes and sections are defined in the
Days that are not selected are skipped when scheduling work packages (and not included in the day count).
diff --git a/config/routes.rb b/config/routes.rb
index 84f2ac8b9bca..8faa0f2241de 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -79,12 +79,13 @@
# Shared route concerns
# TODO: Add description how to configure controller to support shares
concern :shareable do
- resources :members, path: :shares, controller: "shares", only: %i[index create update destroy] do
+ resources :members, path: "shares", controller: "shares", only: %i[index create update destroy] do
member do
post "resend_invite" => "shares#resend_invite"
end
collection do
+ get :dialog, to: "shares#dialog"
patch :bulk, to: "shares#bulk_update"
put :bulk, to: "shares#bulk_update"
delete :bulk, to: "shares#bulk_destroy"
@@ -196,11 +197,11 @@
end
resources :project_queries, only: %i[show new create update destroy], controller: "projects/queries" do
+ concerns :shareable
+
member do
get :rename
-
- post :publish
- post :unpublish
+ post :toggle_public
end
end
@@ -537,7 +538,9 @@
get "/bulk" => "bulk#destroy"
end
- resources :work_packages, only: [:index], concerns: [:shareable] do
+ resources :work_packages, only: [:index] do
+ concerns :shareable
+
# move bulk of wps
get "move/new" => "work_packages/moves#new", on: :collection, as: "new_move"
post "move" => "work_packages/moves#create", on: :collection, as: "move"
diff --git a/frontend/src/turbo/setup.ts b/frontend/src/turbo/setup.ts
index 0f10dd45405e..9bd905bbb9a1 100644
--- a/frontend/src/turbo/setup.ts
+++ b/frontend/src/turbo/setup.ts
@@ -2,7 +2,6 @@ import '../typings/shims.d.ts';
import * as Turbo from '@hotwired/turbo';
import { registerDialogStreamAction } from './dialog-stream-action';
import { addTurboEventListeners } from './turbo-event-listeners';
-import { ModalDialogElement } from '@openproject/primer-view-components/app/components/primer/alpha/modal_dialog';
// Disable default turbo-drive for now as we don't need it for now AND it breaks angular routing
Turbo.session.drive = false;
@@ -18,15 +17,3 @@ document.addEventListener('turbo:frame-missing', (event:CustomEvent) => {
event.preventDefault();
visit(response.url);
});
-
-// Close the primer dialog when the form inside has been submitted with a success response
-// It is necessary to close the primer dialog using the `close()` method, otherwise
-// it will leave an overflow:hidden attribute on the body, which prevents scrolling on the page.
-document.addEventListener('turbo:submit-end', (event:CustomEvent) => {
- const { detail: { success }, target } = event as { detail:{ success:boolean }, target:EventTarget };
-
- if (success && target instanceof HTMLFormElement) {
- const dialog = target.closest('modal-dialog') as ModalDialogElement;
- dialog && dialog.close(true);
- }
-});
diff --git a/frontend/src/turbo/turbo-event-listeners.ts b/frontend/src/turbo/turbo-event-listeners.ts
index 23a76c58a1c1..3e9b24c02341 100644
--- a/frontend/src/turbo/turbo-event-listeners.ts
+++ b/frontend/src/turbo/turbo-event-listeners.ts
@@ -1,5 +1,9 @@
export function addTurboEventListeners() {
- // Close the primer dialog when the form inside has been submitted with a success response
+ // Close the primer dialog when the form inside has been submitted with a success response.
+ //
+ // If you want to keep the dialog open even after a successful form submission, you can add the
+ // `data-keep-open-on-submit="true"` attribute to the dialog element.
+ //
// It is necessary to close the primer dialog using the `close()` method, otherwise
// it will leave an overflow:hidden attribute on the body, which prevents scrolling on the page.
document.addEventListener('turbo:submit-end', (event:CustomEvent) => {
@@ -7,7 +11,10 @@ export function addTurboEventListeners() {
if (success && target instanceof HTMLFormElement) {
const dialog = target.closest('dialog') as HTMLDialogElement;
- dialog && dialog.close();
+
+ if (dialog && dialog.dataset.keepOpenOnSubmit !== 'true') {
+ dialog.close();
+ }
}
});
}
diff --git a/lookbook/docs/patterns/05-dialogs.md.erb b/lookbook/docs/patterns/05-dialogs.md.erb
index 75046c837aad..e39a97bd4324 100644
--- a/lookbook/docs/patterns/05-dialogs.md.erb
+++ b/lookbook/docs/patterns/05-dialogs.md.erb
@@ -1,13 +1,9 @@
As of now, this page is still a stub. In particular, the considerations on when and where to apply Dialogs are still missing.
-
For now, it can be stated that OpenProject employs Dialogs for more purposes than GitHub [Primer specifies](https://primer.style/components/dialog).
-
## Async dialogs as turbo streams
-
Primer dialogs need to be rendered and tied to a button, resulting in their content always being present.
If you have any non-trivial content that should be displayed in a dialog, you should use the async dialog pattern.
-
To render dialogs asynchronously, you use a Button/Link/IconButton component as a trigger and add the following
attributes to it:
@@ -152,6 +148,23 @@ class TestController < ApplicationControler
end
```
+### Dialogs that should stay open on form submission
+
+Because we normally want to close the dialog when a form is submitted successfully, we have implemented this as the default behaviour. As described in the last section,
+when the submission returns a 4xx or 5xx status code, the dialog will stay open and display the validation errors and will be closed when the form is submitted successfully.
+
+If you want to keep a dialog open even after a successful form submission, you can use the `data-keep-open-on-submit="true"` attribute on the dialog. This will prevent the dialog
+from closing even if the form submission is successful.
+
+```erb
+<%%= turbo_stream.dialog do
+ render(Primer::Alpha::Dialog.new(data: { 'keep-open-on-submit': true }) do |d|
+ # ...
+ end
+end
+%>
+```
+
## Special kinds of dialogs
We have multiple kind of dialogs that re-appear throughout the whole application. In order to make sure, they all look and feel alike, we further specify them here.
@@ -198,7 +211,6 @@ the form via the `form` attribute.
In pseudo html, the logical format is then like this:
```html
-
-
```
<%= embed OpenProject::Common::DialogPreview, :form %>
-
#### Where it's used
-
- WorkPackage → Meeting Tab → Add meeting to WorkPackage
### User nudging modal
-
The idea of nudging modals is to promote users to perform actions that are not 100% mandatory but might be interesting for them. For example, after creating a type, an admin would like to add the new type to a project and configure a workflow for it. Another example is to grant personal access to a recently created storage:
-
-
#### Accessibility considerations
-
To be written down..
#### Where it's used
-
- Admin → Storages → OAuth Grant Access
diff --git a/lookbook/previews/shares/share_dialog_component_preview.rb b/lookbook/previews/shares/share_dialog_component_preview.rb
new file mode 100644
index 000000000000..8818bcb4e508
--- /dev/null
+++ b/lookbook/previews/shares/share_dialog_component_preview.rb
@@ -0,0 +1,23 @@
+module Shares
+ class ShareDialogComponentPreview < Lookbook::Preview
+ def project_query
+ user = FactoryBot.build_stubbed(:admin)
+ query = FactoryBot.build_stubbed(:project_query, user:)
+ strategy = SharingStrategies::ProjectQueryStrategy.new(query, user:)
+ errors = []
+ shares = []
+
+ render(Shares::ShareDialogComponent.new(strategy:, shares:, errors:))
+ end
+
+ def work_package
+ user = FactoryBot.build_stubbed(:admin)
+ work_package = FactoryBot.build_stubbed(:work_package)
+ strategy = SharingStrategies::WorkPackageStrategy.new(work_package, user:)
+ errors = []
+ shares = []
+
+ render(Shares::ShareDialogComponent.new(strategy:, shares:, errors:))
+ end
+ end
+end
diff --git a/spec/controllers/projects/queries_controller_spec.rb b/spec/controllers/projects/queries_controller_spec.rb
index dc8a82ad9f1b..9793088a9fb9 100644
--- a/spec/controllers/projects/queries_controller_spec.rb
+++ b/spec/controllers/projects/queries_controller_spec.rb
@@ -263,11 +263,11 @@
end
end
- describe "#publish" do
+ describe "#toggle_public" do
let(:service_class) { Queries::Projects::ProjectQueries::PublishService }
it "requires login" do
- put "publish", params: { id: 42 }
+ post "toggle_public", params: { id: 42 }
expect(response).not_to be_successful
end
@@ -292,7 +292,7 @@
end
it "calls publish service on query" do
- put "publish", params: { id: 42 }
+ post "toggle_public", params: { id: 42 }
expect(service_instance).to have_received(:call).with(query_params)
end
@@ -301,7 +301,7 @@
it "redirects to projects" do
allow(I18n).to receive(:t).with("lists.publish.success").and_return("foo")
- put "publish", params: { id: 42 }
+ post "toggle_public", params: { id: 42 }
expect(flash[:notice]).to eq("foo")
expect(response).to redirect_to(projects_path(query_id: query.id))
@@ -319,7 +319,7 @@
it "renders projects/index" do
allow(I18n).to receive(:t).with("lists.publish.failure", errors: "something\nwent\nwrong").and_return("bar")
- put "publish", params: { id: 42 }
+ post "toggle_public", params: { id: 42 }
expect(flash[:error]).to eq("bar")
expect(response).to render_template("projects/index")
@@ -328,80 +328,7 @@
it "passes variables to template" do
allow(controller).to receive(:render).and_call_original
- put "update", params: { id: 42 }
-
- expect(controller).to have_received(:render).with(include(locals: { query:, state: :edit }))
- end
- end
- end
- end
-
- describe "#unpublish" do
- let(:service_class) { Queries::Projects::ProjectQueries::PublishService }
-
- it "requires login" do
- put "unpublish", params: { id: 42 }
-
- expect(response).not_to be_successful
- end
-
- context "when logged in" do
- let(:query) { build_stubbed(:project_query, user:) }
- let(:query_id) { "42" }
- let(:query_params) { { public: false } }
- let(:service_instance) { instance_double(service_class) }
- let(:service_result) { instance_double(ServiceResult, success?: success?, result: query) }
- let(:success?) { true }
-
- before do
- allow(controller).to receive(:permitted_query_params).and_return(query_params)
- scope = instance_double(ActiveRecord::Relation)
- allow(ProjectQuery).to receive(:visible).and_return(scope)
- allow(scope).to receive(:find).with(query_id).and_return(query)
- allow(service_class).to receive(:new).with(model: query, user:).and_return(service_instance)
- allow(service_instance).to receive(:call).with(query_params).and_return(service_result)
-
- login_as user
- end
-
- it "calls publish service on query" do
- put "unpublish", params: { id: 42 }
-
- expect(service_instance).to have_received(:call).with(query_params)
- end
-
- context "when service call succeeds" do
- it "redirects to projects" do
- allow(I18n).to receive(:t).with("lists.unpublish.success").and_return("foo")
-
- put "unpublish", params: { id: 42 }
-
- expect(flash[:notice]).to eq("foo")
- expect(response).to redirect_to(projects_path(query_id: query.id))
- end
- end
-
- context "when service call fails" do
- let(:success?) { false }
- let(:errors) { instance_double(ActiveModel::Errors, full_messages: ["something", "went", "wrong"]) }
-
- before do
- allow(service_result).to receive(:errors).and_return(errors)
- end
-
- it "renders projects/index" do
- allow(I18n).to receive(:t).with("lists.unpublish.failure", errors: "something\nwent\nwrong").and_return("bar")
-
- put "unpublish", params: { id: 42 }
-
- expect(flash[:error]).to eq("bar")
- expect(response).to render_template("projects/index")
- end
-
- it "passes variables to template" do
- allow(controller).to receive(:render).and_call_original
-
- put "unpublish", params: { id: 42 }
+ post "toggle_public", params: { id: 42 }
expect(controller).to have_received(:render).with(include(locals: { query:, state: :edit }))
end